feat: theme chrome by palette, selectable text, wap fallback, and library pages

This commit is contained in:
randogoth 2026-10-11 11:48:24 +03:00
parent c104c2026b
commit 56f3d959fa
12 changed files with 534 additions and 51 deletions

View file

@ -5,6 +5,7 @@ import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../mews/document.dart';
import '../actors/fetch_actor.dart';
import '../internal_pages.dart';
/// What went wrong while browsing. The presentation layer maps these to
/// user-facing messages; no raw codes cross into the UI.
@ -27,12 +28,17 @@ class BrokenPage extends BrowseError {
/// dispatches fetch commands to the fetch actor. Local-first: a cached page is
/// revalidated with the server but survives any network failure.
class BrowserController {
BrowserController({required MewsDatabase db, required Actor<FetchCommand, FetchResult> fetch})
: _db = db,
_fetch = fetch;
BrowserController({
required MewsDatabase db,
required Actor<FetchCommand, FetchResult> fetch,
InternalPages? internalPages,
}) : _db = db,
_fetch = fetch,
_internalPages = internalPages;
final MewsDatabase _db;
final Actor<FetchCommand, FetchResult> _fetch;
final InternalPages? _internalPages;
/// The stack of visited pages; the reader is at [_stackIndex].
final List<String> _stack = [];
@ -54,14 +60,27 @@ class BrowserController {
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
/// Normalizes what the reader typed into an address: bare domains become
/// https URLs.
/// https URLs; anything already carrying a scheme (mews:, https:, ...) is
/// left alone.
static String normalizeAddress(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return '';
if (trimmed.contains('://')) return trimmed;
if (_schemePattern.hasMatch(trimmed)) return trimmed;
return 'https://$trimmed';
}
static final _schemePattern = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*:');
/// The wap. subdomain equivalent of [url], or null when the host already
/// starts with it or the address is not a plain web URL.
static String? wapUrlOf(String url) {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null;
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
if (uri.host.startsWith('wap.')) return null;
return uri.replace(host: 'wap.${uri.host}').toString();
}
Future<void> open(String input) async {
final url = normalizeAddress(input);
if (url.isEmpty) return;
@ -70,6 +89,23 @@ class BrowserController {
_stackIndex++;
_updateNeighbors();
await _loadCurrent();
// Mews sites commonly live on a wap. subdomain; try that before showing
// an error when the address as typed does not work out.
if (error.value != null && _stack[_stackIndex] == url) {
final wap = wapUrlOf(url);
if (wap != null) {
final firstError = error.value;
_stack[_stackIndex] = wap;
_updateNeighbors();
await _loadCurrent();
// The address as typed is the closer match; keep its error when the
// wap. attempt does not work either.
if (error.value != null && firstError != null) {
error.value = firstError;
}
}
}
}
Future<void> back() async {
@ -142,6 +178,10 @@ class BrowserController {
return;
}
final url = _stack[index];
if (_internalPages?.handles(url) ?? false) {
target.value = await _internalPages!.pageFor(url);
return;
}
final cached = await _db.cachedPage(url);
if (_stackIndex != expectedIndex) return;
if (cached == null) {
@ -159,6 +199,22 @@ class BrowserController {
loading.value = true;
error.value = null;
// Internal pages are built from the local database, never fetched or
// cached; they join the stack like any other page.
if (_internalPages?.handles(url) ?? false) {
final page = await _internalPages!.pageFor(url);
loading.value = false;
if (page != null) {
currentPage.value = page;
error.value = null;
visitedUrls.value = {...visitedUrls.value, url};
} else {
currentPage.value = null;
error.value = const LoadFailed();
}
return;
}
final cached = await _db.cachedPage(url);
final result = await _fetch.send(FetchPage(
url: url,

View file

@ -0,0 +1,165 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'controllers/reader_settings_controller.dart'
show LibraryController;
/// Builds the internal History, Bookmarks and Subscriptions pages as real
/// [MewsPage]s: they render like any other page and take part in the walk
/// history, so swiping and back/forward treat them like visited pages.
class InternalPages {
InternalPages(
{required MewsDatabase db,
required LibraryController library,
required Strings strings})
: _db = db,
_library = library,
_strings = strings;
static const historyUrl = 'mews:history';
static const bookmarksUrl = 'mews:bookmarks';
static const subscriptionsUrl = 'mews:subscriptions';
final MewsDatabase _db;
final LibraryController _library;
final Strings _strings;
/// Whether [url] is one of the internal pages.
bool handles(String url) => isInternal(url);
/// Whether [url] belongs to the internal mews: scheme.
static bool isInternal(String url) =>
Uri.tryParse(url)?.scheme == 'mews';
/// The internal page for [url], or null when the name is unknown.
Future<MewsPage?> pageFor(String url) async {
// mews:history is an opaque URI: the page name is the path, not a host.
switch (Uri.tryParse(url)?.path) {
case 'history':
return _history(url);
case 'bookmarks':
return _bookmarks(url);
case 'subscriptions':
return _subscriptions(url);
default:
return null;
}
}
Future<MewsPage> _history(String url) async {
final visits = await _db.recentVisits();
return _page(
url: url,
title: _strings.t('history-title'),
empty: _strings.t('history-empty'),
entries: [
for (final visit in visits)
_Entry(
url: visit.url,
// Dated links are the Mews posts pattern, so history reads like
// any other entry list.
label: '${_day(visit.visitedAt)} ${_clock(visit.visitedAt)}',
title: visit.title ?? visit.url,
),
],
);
}
Future<MewsPage> _bookmarks(String url) async {
final bookmarks = await _db.bookmarksByDate();
return _page(
url: url,
title: _strings.t('bookmarks-title'),
empty: _strings.t('bookmarks-empty'),
entries: [
for (final bookmark in bookmarks)
_Entry(
url: bookmark.url,
label: _day(bookmark.createdAt),
title: bookmark.title ?? bookmark.url,
),
],
);
}
Future<MewsPage> _subscriptions(String url) async {
final subscriptions = await _db.allSubscriptions();
return _page(
url: url,
title: _strings.t('subscriptions-title'),
empty: _strings.t('subscriptions-empty'),
entries: [
for (final subscription in subscriptions)
_Entry(
url: subscription.url,
label: _day(subscription.createdAt),
title: (subscription.title ?? subscription.url) +
_newPostsNote(subscription.url),
),
],
);
}
String _newPostsNote(String siteUrl) {
final count = _library.newEntries.value[siteUrl]?.length ?? 0;
if (count == 0) return '';
final note = count == 1
? _strings.t('subscriptions-new-one')
: _strings.t('subscriptions-new-many', {'count': count});
return ' \u2014 $note';
}
MewsPage _page({
required String url,
required String title,
required String empty,
required List<_Entry> entries,
}) {
final blocks = <MewsBlock>[
HeadingBlock(1, [MewsText(title)]),
if (entries.isEmpty)
ParagraphBlock([MewsText(empty)])
else
ListBlock(
ordered: false,
start: 1,
items: [
for (final entry in entries)
MewsListItem([
ParagraphBlock([
MewsLink(entry.url, [MewsText('${entry.label} ${entry.title}')]),
]),
]),
],
),
];
return MewsPage(
url: url,
// Internal pages carry a marker so they render as Mews pages, not as
// plainly-shown external ones.
specVersion: 'internal',
title: title,
body: blocks,
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
entries: const [],
);
}
String _day(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}';
String _clock(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}';
String _two(int value) => value.toString().padLeft(2, '0');
}
class _Entry {
const _Entry({required this.url, required this.label, required this.title});
final String url;
final String label;
final String title;
}

View file

@ -91,6 +91,27 @@ class MewsDatabase extends _$MewsDatabase {
return row?.url;
}
/// The most recent external visits, newest first.
Future<List<HistoryItem>> recentVisits({int limit = 50}) async {
final query = (select(historyItems)
..orderBy([
(r) => OrderingTerm.desc(r.visitedAt),
(r) => OrderingTerm.desc(r.id),
])
..limit(limit));
return query.get();
}
/// Bookmarks in the order they were added, newest first.
Future<List<Bookmark>> bookmarksByDate() async {
final query = (select(bookmarks)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
(r) => OrderingTerm.desc(r.id),
]));
return query.get();
}
// Bookmarks -----------------------------------------------------------
Future<List<Bookmark>> allBookmarks() => select(bookmarks).get();

View file

@ -9,7 +9,7 @@ Future<void> main() async {
final strings = Strings();
await strings.load();
final deps = AppDependencies();
final deps = AppDependencies(strings: strings);
await deps.start();
runApp(MewsApp(deps: deps, strings: strings));

View file

@ -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,
),
);
}
}

View file

@ -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> {
),
],
),
),
),
);
}

View file

@ -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,

View file

@ -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(

View file

@ -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.