feat: build v1 Mews client: reader UI, gestures, Drift store, fetch actor

This commit is contained in:
randogoth 2026-10-11 09:33:25 +03:00
parent d5a0fcbf48
commit 01a36e8aee
56 changed files with 7287 additions and 2 deletions

View file

@ -0,0 +1,160 @@
import 'package:actors/actors.dart';
import 'package:http/http.dart' as http;
import '../../mews/document.dart';
import '../../mews/parser.dart';
/// Commands accepted by the fetch actor. Network I/O and XML parsing both
/// happen in the actor's isolate; results return by message only.
sealed class FetchCommand {
const FetchCommand();
}
/// Fetch [url], sending validators when the page is cached. A 304 parses
/// [cachedSource] in place of a body, and a network failure falls back to it,
/// so the reader stays online-transparent and works offline.
class FetchPage extends FetchCommand {
const FetchPage({required this.url, this.etag, this.lastModified, this.cachedSource});
final String url;
final String? etag;
final String? lastModified;
final String? cachedSource;
}
/// Submit a form's fields to its action URL (spec 4.2).
class SubmitForm extends FetchCommand {
const SubmitForm({required this.url, required this.method, required this.fields});
final String url;
final String method;
final Map<String, String> fields;
}
sealed class FetchResult {
const FetchResult();
}
class FetchPageOk extends FetchResult {
const FetchPageOk({
required this.page,
required this.source,
this.etag,
this.lastModified,
this.notModified = false,
});
final MewsPage page;
/// The markup the page was parsed from, for the cache.
final String source;
final String? etag;
final String? lastModified;
/// True when the page came from the local cache (304 or offline).
final bool notModified;
}
class FetchFormOk extends FetchResult {
const FetchFormOk(this.page);
final MewsPage page;
}
class FetchNetworkError extends FetchResult {
const FetchNetworkError();
}
class FetchHttpStatusError extends FetchResult {
const FetchHttpStatusError(this.status);
final int status;
}
class FetchParseError extends FetchResult {
const FetchParseError(this.message, this.source);
final String message;
/// The markup that failed to parse, for the "show page text" option.
final String source;
}
class FetchHandler with Handler<FetchCommand, FetchResult> {
// Identifies the client to servers that vary behavior by user agent, like
// the http-to-https redirects some Mews sites run (spec 7.2).
static const _userAgent = 'Mews/0.1 (Mews client)';
final http.Client _client = http.Client();
@override
Future<FetchResult> handle(FetchCommand message) async {
return switch (message) {
FetchPage(:final url, :final etag, :final lastModified, :final cachedSource) =>
await _fetchPage(url, etag, lastModified, cachedSource),
SubmitForm(:final url, :final method, :final fields) =>
await _submitForm(url, method, fields),
};
}
Future<FetchResult> _fetchPage(
String url, String? etag, String? lastModified, String? cachedSource) async {
final headers = <String, String>{'User-Agent': _userAgent};
if (etag != null) headers['If-None-Match'] = etag;
if (lastModified != null) headers['If-Modified-Since'] = lastModified;
http.Response response;
try {
response = await _client
.get(Uri.parse(url), headers: headers)
.timeout(const Duration(seconds: 20));
} catch (_) {
return _fromCacheOrError(url, cachedSource, etag, lastModified);
}
if (response.statusCode == 304) {
return _fromCacheOrError(url, cachedSource, etag, lastModified);
}
if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode);
final newEtag = response.headers['etag'];
final newLastModified = response.headers['last-modified'];
return _parse(response.body, url, etag: newEtag, lastModified: newLastModified);
}
/// The page could not be revalidated; use the cached copy when there is one.
FetchResult _fromCacheOrError(String url, String? cachedSource, String? etag, String? lastModified) {
if (cachedSource == null) return const FetchNetworkError();
return _parse(cachedSource, url, etag: etag, lastModified: lastModified, notModified: true);
}
FetchResult _parse(String source, String url,
{String? etag, String? lastModified, bool notModified = false}) {
try {
final page = parseMewsPage(source: source, url: url);
return FetchPageOk(
page: page, source: source, etag: etag, lastModified: lastModified, notModified: notModified);
} on MewsParseException catch (e) {
return FetchParseError(e.message, source);
}
}
Future<FetchResult> _submitForm(String url, String method, Map<String, String> fields) async {
final headers = {'User-Agent': _userAgent};
http.Response response;
if (method == 'post') {
response = await _client
.post(Uri.parse(url), headers: headers, body: fields)
.timeout(const Duration(seconds: 20));
} else {
response = await _client
.get(Uri.parse(url).replace(queryParameters: fields), headers: headers)
.timeout(const Duration(seconds: 20));
}
if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode);
return _parse(response.body, url);
}
@override
Future<void> close() async => _client.close();
}

View file

@ -0,0 +1,151 @@
import 'package:actors/actors.dart';
import 'package:drift/drift.dart' show Value;
import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../mews/document.dart';
import '../actors/fetch_actor.dart';
/// What went wrong while browsing. The presentation layer maps these to
/// user-facing messages; no raw codes cross into the UI.
sealed class BrowseError {
const BrowseError();
}
class LoadFailed extends BrowseError {
const LoadFailed();
}
class BrokenPage extends BrowseError {
const BrokenPage(this.source);
/// The markup that failed to parse; "show page text" recovers its content.
final String source;
}
/// Coordinates navigation: owns the signals the reader UI observes and
/// 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;
final MewsDatabase _db;
final Actor<FetchCommand, FetchResult> _fetch;
/// The stack of visited pages; the reader is at [_stackIndex].
final List<String> _stack = [];
int _stackIndex = -1;
final currentPage = signal<MewsPage?>(null);
final loading = signal(false);
final error = signal<BrowseError?>(null);
/// Pages opened this session, so links can show the visited color.
final visitedUrls = signal(<String>{});
bool get canGoBack => _stackIndex > 0;
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
/// Normalizes what the reader typed into an address: bare domains become
/// https URLs.
static String normalizeAddress(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return '';
if (trimmed.contains('://')) return trimmed;
return 'https://$trimmed';
}
Future<void> open(String input) async {
final url = normalizeAddress(input);
if (url.isEmpty) return;
_stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(url);
_stackIndex++;
await _loadCurrent();
}
Future<void> back() async {
if (!canGoBack) return;
_stackIndex--;
await _loadCurrent();
}
Future<void> forward() async {
if (!canGoForward) return;
_stackIndex++;
await _loadCurrent();
}
Future<void> reload() => _loadCurrent();
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
loading.value = true;
error.value = null;
final result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields));
loading.value = false;
switch (result) {
case FetchFormOk(:final page):
_stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(page.url);
_stackIndex++;
_show(page);
case _:
error.value = const LoadFailed();
}
}
/// Reopens the most recent page from the previous session, if any.
Future<void> restoreLastSession() async {
final url = await _db.lastVisitedUrl();
if (url == null) return;
_stack.add(url);
_stackIndex = 0;
await _loadCurrent();
}
Future<void> _loadCurrent() async {
if (_stackIndex < 0) return;
final url = _stack[_stackIndex];
loading.value = true;
error.value = null;
final cached = await _db.cachedPage(url);
final result = await _fetch.send(FetchPage(
url: url,
etag: cached?.etag,
lastModified: cached?.lastModified,
cachedSource: cached?.source,
));
loading.value = false;
switch (result) {
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified):
currentPage.value = page;
error.value = null;
visitedUrls.value = {...visitedUrls.value, url};
if (!notModified) {
await _db.saveCachedPage(CachedPagesCompanion.insert(
url: url,
source: source,
title: Value(page.title),
etag: Value(etag),
lastModified: Value(lastModified),
));
await _db.recordVisit(url, page.title);
}
case FetchParseError(:final source):
currentPage.value = null;
error.value = BrokenPage(source);
case _:
currentPage.value = null;
error.value = const LoadFailed();
}
}
void _show(MewsPage page) {
currentPage.value = page;
_db.recordVisit(page.url, page.title);
}
}

View file

@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../mews/document.dart';
/// The reader's presentation choices (spec 8.2) plus the per-app limits they
/// move within. Values persist through ReaderSettings in the database.
class ReaderSettingsController {
ReaderSettingsController(this._db);
final MewsDatabase _db;
static const minScale = 0.75;
static const maxScale = 2.0;
static const scaleStep = 0.125;
final font = signal('Atkinson Hyperlegible');
final fontScale = signal(1.0);
final themeMode = signal('system'); // system | light | dark
final variant = signal('site'); // site | warm | cool | green | mono
Future<void> load() async {
final settings = await _db.readSettings();
if (settings == null) return;
font.value = settings.font;
fontScale.value = settings.fontScale;
themeMode.value = settings.themeMode;
variant.value = settings.variant;
}
Future<void> setFont(String family) async {
font.value = family;
await _persist();
}
Future<void> decreaseSize() => _setScale(fontScale.value - scaleStep);
Future<void> increaseSize() => _setScale(fontScale.value + scaleStep);
Future<void> _setScale(double value) async {
if (value < minScale || value > maxScale) return;
fontScale.value = value;
await _persist();
}
Future<void> setThemeMode(String mode) async {
themeMode.value = mode;
await _persist();
}
Future<void> setVariant(String variant) async {
this.variant.value = variant;
await _persist();
}
Future<void> _persist() => _db.saveSettings(
font: font.value,
fontScale: fontScale.value,
themeMode: themeMode.value,
variant: variant.value,
);
}
/// Bookmarks and subscriptions. Signals expose derived state (which URLs are
/// bookmarked or subscribed); subscription refreshes piggyback on page loads,
/// comparing the page's dated links against the known entry URLs.
class LibraryController {
LibraryController(this._db);
final MewsDatabase _db;
final bookmarkedUrls = signal(<String>{});
final subscribedUrls = signal(<String>{});
/// New posts found on the most recent refresh of a subscribed site, keyed by
/// site URL. Empty when there is nothing to report.
final newEntries = signal(<String, List<MewsEntry>>{});
Future<void> load() async {
final bookmarks = await _db.allBookmarks();
bookmarkedUrls.value = bookmarks.map((b) => b.url).toSet();
final subs = await _db.allSubscriptions();
subscribedUrls.value = subs.map((s) => s.url).toSet();
}
bool isBookmarked(String url) => bookmarkedUrls.value.contains(url);
bool isSubscribed(String url) => subscribedUrls.value.contains(url);
Future<void> toggleBookmark(String url, String? title) async {
if (isBookmarked(url)) {
await _db.removeBookmark(url);
bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url);
} else {
await _db.addBookmark(url, title);
bookmarkedUrls.value = {...bookmarkedUrls.value, url};
}
}
Future<void> toggleSubscription(String url, String? title, List<MewsEntry> entries) async {
if (isSubscribed(url)) {
await _db.removeSubscription(url);
subscribedUrls.value = {...subscribedUrls.value}..remove(url);
newEntries.value = {...newEntries.value}..remove(url);
} else {
await _db.addSubscription(url, title, entries.map((e) => e.url).toList());
subscribedUrls.value = {...subscribedUrls.value, url};
newEntries.value = {...newEntries.value}..remove(url);
}
}
/// Revalidates every subscribed page and reports entries the reader has not
/// seen yet (spec 6.1). Called when a subscribed page is opened.
Future<void> refreshSubscription(MewsPage page) async {
final subs = await _db.allSubscriptions();
final sub = subs.where((s) => s.url == page.url).firstOrNull;
if (sub == null) return;
final known = (jsonDecode(sub.entryUrls) as List).cast<String>().toSet();
final fresh = page.entries.where((e) => !known.contains(e.url)).toList();
if (fresh.isEmpty) {
// Nothing new this time, so the site stops being flagged.
newEntries.value = {...newEntries.value}..remove(sub.url);
await _db.updateSubscription(sub.url, lastCheckedAt: DateTime.now());
return;
}
newEntries.value = {...newEntries.value, page.url: fresh};
await _db.updateSubscription(
sub.url,
entryUrls: [...known, ...fresh.map((e) => e.url)],
lastCheckedAt: DateTime.now(),
);
}
}

View file

@ -0,0 +1,157 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
part 'database.g.dart';
/// Visited pages, newest first; the most recent row is the app's start page.
class HistoryItems extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)();
}
class Bookmarks extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
/// A subscription to one page (spec 6). [entryUrls] is a JSON list of the
/// entry URLs already seen, so a refresh can report only new posts.
class Subscriptions extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
TextColumn get entryUrls => text().withDefault(const Constant('[]'))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get lastCheckedAt => dateTime().nullable()();
}
/// Single-row table (id 0) holding the reader's presentation choices.
class ReaderSettings extends Table {
IntColumn get id => integer().withDefault(const Constant(0))();
TextColumn get font => text().withDefault(const Constant('Atkinson Hyperlegible'))();
RealColumn get fontScale => real().withDefault(const Constant(1.0))();
TextColumn get themeMode => text().withDefault(const Constant('system'))();
TextColumn get variant => text().withDefault(const Constant('site'))();
@override
Set<Column> get primaryKey => {id};
}
/// The fetched source of a page, kept so the reader can reopen it offline.
class CachedPages extends Table {
TextColumn get url => text()();
TextColumn get title => text().nullable()();
TextColumn get source => text()();
TextColumn get etag => text().nullable()();
TextColumn get lastModified => text().nullable()();
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {url};
}
@DriftDatabase(tables: [HistoryItems, Bookmarks, Subscriptions, ReaderSettings, CachedPages])
class MewsDatabase extends _$MewsDatabase {
MewsDatabase() : super(_open());
/// For tests.
MewsDatabase.test(super.executor);
@override
int get schemaVersion => 1;
static QueryExecutor _open() => LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
return NativeDatabase.createInBackground(File(p.join(dir.path, 'mews.sqlite')));
});
// History -------------------------------------------------------------
Future<void> recordVisit(String url, String? title) =>
(into(historyItems).insert(HistoryItemsCompanion.insert(url: url, title: Value(title))));
Future<String?> lastVisitedUrl() async {
final query = (select(historyItems)
..orderBy([
(r) => OrderingTerm.desc(r.visitedAt),
// Tiebreak pages recorded within the same clock tick.
(r) => OrderingTerm.desc(r.id),
])
..limit(1));
final row = await query.getSingleOrNull();
return row?.url;
}
// Bookmarks -----------------------------------------------------------
Future<List<Bookmark>> allBookmarks() => select(bookmarks).get();
Future<void> addBookmark(String url, String? title) => into(bookmarks).insert(
BookmarksCompanion.insert(url: url, title: Value(title)),
mode: InsertMode.insertOrIgnore);
Future<void> removeBookmark(String url) =>
(delete(bookmarks)..where((r) => r.url.equals(url))).go();
// Subscriptions ---------------------------------------------------------
Future<void> addSubscription(String url, String? title, List<String> entryUrls) => into(
subscriptions)
.insert(
SubscriptionsCompanion.insert(
url: url, title: Value(title), entryUrls: Value(jsonEncode(entryUrls))),
mode: InsertMode.insertOrIgnore);
Future<void> removeSubscription(String url) =>
(delete(subscriptions)..where((r) => r.url.equals(url))).go();
Future<List<Subscription>> allSubscriptions() => select(subscriptions).get();
Future<void> updateSubscription(String url,
{String? title, List<String>? entryUrls, DateTime? lastCheckedAt}) =>
(update(subscriptions)..where((r) => r.url.equals(url))).write(SubscriptionsCompanion(
title: Value(title),
entryUrls: entryUrls == null ? const Value.absent() : Value(jsonEncode(entryUrls)),
lastCheckedAt: Value(lastCheckedAt),
));
// Settings ---------------------------------------------------------------
Future<ReaderSetting?> readSettings() async {
final query = select(readerSettings)..where((r) => r.id.equals(0));
return query.getSingleOrNull();
}
Future<void> saveSettings({
required String font,
required double fontScale,
required String themeMode,
required String variant,
}) =>
into(readerSettings).insert(
ReaderSettingsCompanion.insert(
id: const Value(0),
font: Value(font),
fontScale: Value(fontScale),
themeMode: Value(themeMode),
variant: Value(variant),
),
mode: InsertMode.insertOrReplace);
// Cache --------------------------------------------------------------------
Future<CachedPage?> cachedPage(String url) async =>
(select(cachedPages)..where((r) => r.url.equals(url))).getSingleOrNull();
Future<void> saveCachedPage(CachedPagesCompanion entry) =>
into(cachedPages).insert(entry, mode: InsertMode.insertOrReplace);
}

File diff suppressed because it is too large Load diff

39
app/lib/l10n/strings.dart Normal file
View file

@ -0,0 +1,39 @@
import 'package:fluent/fluent.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart';
/// Loads and formats the app's Fluent messages. All user-facing strings come
/// from locales/en-US/main.ftl; lookups fall back to the message ID when a
/// message is missing so that missing translations are obvious in the UI.
class Strings {
final FluentBundle _bundle = FluentBundle('en-US');
/// Loads the messages from the app's asset bundle.
Future<void> load() async {
final source = await rootBundle.loadString('locales/en-US/main.ftl');
loadSource(source);
}
/// Loads messages from an already-read source, so tests can read the file
/// from disk instead of the asset bundle.
void loadSource(String source) {
_bundle.addMessages(source);
}
String t(String id, [Map<String, Object?> args = const {}]) {
return _bundle.format(id, args: args) ?? id;
}
}
/// Provides [Strings] to the widget tree.
class StringsScope extends InheritedWidget {
const StringsScope({required this.strings, required super.child, super.key});
final Strings strings;
static Strings of(BuildContext context) =>
context.getInheritedWidgetOfExactType<StringsScope>()!.strings;
@override
bool updateShouldNotify(StringsScope oldWidget) => false;
}

16
app/lib/main.dart Normal file
View file

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'l10n/strings.dart';
import 'presentation/app.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final strings = Strings();
await strings.load();
final deps = AppDependencies();
await deps.start();
runApp(MewsApp(deps: deps, strings: strings));
}

291
app/lib/mews/document.dart Normal file
View file

@ -0,0 +1,291 @@
/// Document model for Mews pages (mews-spec/SPEC.md sections 3 to 6).
/// Pure data: no Flutter imports, so it can be built inside an isolate.
library;
/// The `body` variant class a page carries (spec 5.3), or [plain] when absent.
enum MewsVariant { plain, warm, cool, green, mono }
/// A parsed page. [specVersion] holds the conformance marker's value and is
/// null on a page without the marker, which must be shown plainly (spec 8.1).
class MewsPage {
const MewsPage({
required this.url,
required this.body,
required this.lang,
required this.direction,
required this.variant,
required this.entries,
this.specVersion,
this.title,
this.description,
this.author,
this.atomFeedHref,
});
final String url;
final List<MewsBlock> body;
final String lang;
final TextDirection direction;
final MewsVariant variant;
final List<MewsEntry> entries;
final String? specVersion;
final String? title;
final String? description;
final String? author;
final String? atomFeedHref;
bool get isMews => specVersion != null;
}
/// TextDirection without importing Flutter: pages with dir="rtl" render
/// right-to-left (spec 3).
enum TextDirection { ltr, rtl }
/// A dated link that starts a list item (spec 6.1).
class MewsEntry {
const MewsEntry({required this.url, required this.date, required this.title});
final String url;
final DateTime date;
final String title;
}
sealed class MewsBlock {
const MewsBlock();
}
class HeadingBlock extends MewsBlock {
const HeadingBlock(this.level, this.inline);
final int level; // 1 to 6
final List<MewsInline> inline;
}
class ParagraphBlock extends MewsBlock {
const ParagraphBlock(this.inline);
final List<MewsInline> inline;
}
class QuoteBlock extends MewsBlock {
const QuoteBlock(this.children);
final List<MewsBlock> children;
}
class PreBlock extends MewsBlock {
const PreBlock(this.text);
final String text;
}
class AddressBlock extends MewsBlock {
const AddressBlock(this.inline);
final List<MewsInline> inline;
}
class RuleBlock extends MewsBlock {
const RuleBlock();
}
class ListBlock extends MewsBlock {
const ListBlock({required this.ordered, required this.start, required this.items});
final bool ordered;
final int start;
final List<MewsListItem> items;
}
class MewsListItem {
const MewsListItem(this.blocks);
final List<MewsBlock> blocks;
}
class DefinitionItem {
const DefinitionItem(this.term, this.definition);
final List<MewsInline> term;
final List<MewsInline> definition;
}
class DefinitionListBlock extends MewsBlock {
const DefinitionListBlock(this.items);
final List<DefinitionItem> items;
}
class TableBlock extends MewsBlock {
const TableBlock({required this.rows, this.caption, this.summary});
final List<MewsTableRow> rows;
final String? caption;
final String? summary;
}
class MewsTableRow {
const MewsTableRow(this.cells);
final List<MewsTableCell> cells;
}
class MewsTableCell {
const MewsTableCell({
required this.header,
required this.inline,
this.colspan = 1,
this.rowspan = 1,
});
final bool header;
final List<MewsInline> inline;
final int colspan;
final int rowspan;
}
/// Same-site images load normally; off-site or data: sources are refused by
/// the client without reader action (specs 4.2 and 8.1), and the alt text is
/// shown in their place.
class ImageBlock extends MewsBlock {
const ImageBlock({required this.src, required this.alt, required this.allowed});
final String src;
final String alt;
final bool allowed;
}
class FormBlock extends MewsBlock {
const FormBlock({
required this.action,
required this.method,
required this.fields,
required this.submitLabel,
});
final String action;
final String method;
final List<MewsFormField> fields;
final String? submitLabel;
}
sealed class MewsFormField {
const MewsFormField({required this.name, this.label});
final String name;
final String? label;
}
class MewsTextField extends MewsFormField {
const MewsTextField({
required super.name,
required this.password,
this.value = '',
this.maxLength,
this.inputMode,
super.label,
});
final bool password;
final String value;
final int? maxLength;
final String? inputMode;
}
class MewsCheckboxField extends MewsFormField {
const MewsCheckboxField({
required super.name,
required this.checked,
this.value = 'on',
super.label,
});
final bool checked;
final String value;
}
class MewsRadioField extends MewsFormField {
const MewsRadioField({
required super.name,
required this.checked,
required this.value,
super.label,
});
final bool checked;
final String value;
}
class MewsSelectField extends MewsFormField {
const MewsSelectField({required super.name, required this.options, super.label});
final List<MewsSelectOption> options;
}
class MewsSelectOption {
const MewsSelectOption({required this.value, required this.selected});
final String value;
final bool selected;
}
class MewsTextAreaField extends MewsFormField {
const MewsTextAreaField({
required super.name,
required this.value,
this.rows = 3,
super.label,
});
final String value;
final int rows;
}
/// A hidden input; kept in the model so it round-trips on submit.
class MewsHiddenField extends MewsFormField {
const MewsHiddenField({required super.name, required this.value});
final String value;
}
/// One or more inputs of kind submit/reset collapse into this control.
class MewsSubmitField extends MewsFormField {
const MewsSubmitField({required this.reset, super.label, super.name = ''});
final bool reset;
}
sealed class MewsInline {
const MewsInline();
}
class MewsText extends MewsInline {
const MewsText(this.text);
final String text;
}
enum EmphasisStyle { emphasis, strong, code, sample, variable, term, quote }
/// em, i, cite and abbr map to [EmphasisStyle.emphasis]; strong and b to
/// [EmphasisStyle.strong]; kbd and samp to [EmphasisStyle.sample]; var to
/// [EmphasisStyle.variable]; dfn to [EmphasisStyle.term]; q to
/// [EmphasisStyle.quote]. The distinction keeps the renderer honest about what
/// authors meant, per the reader-controls-presentation principle.
class MewsEmphasis extends MewsInline {
const MewsEmphasis(this.style, this.children);
final EmphasisStyle style;
final List<MewsInline> children;
}
class MewsLink extends MewsInline {
const MewsLink(this.href, this.children);
final String href;
final List<MewsInline> children;
}
class MewsLineBreak extends MewsInline {
const MewsLineBreak();
}

434
app/lib/mews/parser.dart Normal file
View file

@ -0,0 +1,434 @@
import 'package:xml/xml.dart';
import 'document.dart';
/// Thrown when a page is not well-formed XML; the client must show a clear
/// error, never guess (spec 8.1).
class MewsParseException implements Exception {
const MewsParseException(this.message);
final String message;
@override
String toString() => message;
}
final _entryDatePattern = RegExp(r'^(\d{4}-\d{2}-\d{2}) (.+)$');
/// Parses a fetched document into a [MewsPage]. Pages are parsed as XML
/// whatever the MIME type (spec 7.1). Elements outside the Mews subset keep
/// their text content (so ordinary XHTML still renders) but scripts, styles
/// and objects are dropped entirely, and no author styling can enter the model.
MewsPage parseMewsPage({required String source, required String url}) {
XmlDocument doc;
try {
doc = XmlDocument.parse(source);
} on XmlException catch (e) {
throw MewsParseException(e.toString());
}
final html = doc.rootElement;
if (html.name.qualified != 'html') {
throw const MewsParseException('The root element is not html.');
}
final head = html.findElements('head').firstOrNull;
final bodyElement = html.findElements('body').firstOrNull;
if (head == null || bodyElement == null) {
throw const MewsParseException('The page has no head or no body.');
}
final headData = _parseHead(head);
final blocks = <MewsBlock>[];
for (final child in bodyElement.children.whereType<XmlElement>()) {
_appendBlocks(child, url, blocks);
}
final dir = _dirOf(html) ?? _dirOf(bodyElement);
return MewsPage(
url: url,
specVersion: headData.specVersion,
title: headData.title,
description: headData.description,
author: headData.author,
atomFeedHref: headData.atomFeedHref,
lang: html.getAttribute('xml:lang') ??
html.getAttribute('lang') ??
bodyElement.getAttribute('lang') ??
'en',
direction: dir == 'rtl' ? TextDirection.rtl : TextDirection.ltr,
variant: _variantOf(bodyElement.getAttribute('class')),
entries: _entriesOf(bodyElement, url),
body: blocks,
);
}
class _HeadData {
String? title;
String? description;
String? author;
String? specVersion;
String? atomFeedHref;
}
_HeadData _parseHead(XmlElement head) {
final data = _HeadData();
for (final element in head.children.whereType<XmlElement>()) {
switch (element.name.qualified) {
case 'title':
data.title = element.innerText.trim();
case 'meta':
final name = element.getAttribute('name');
if (name == 'mews-profile') {
data.specVersion = element.getAttribute('content');
} else if (name == 'description') {
data.description = element.getAttribute('content');
} else if (name == 'author') {
data.author = element.getAttribute('content');
}
case 'link':
final rel = element.getAttribute('rel');
final type = element.getAttribute('type');
if (rel == 'alternate' && type == 'application/atom+xml') {
data.atomFeedHref = element.getAttribute('href');
}
}
}
return data;
}
String? _dirOf(XmlElement element) => element.getAttribute('dir');
MewsVariant _variantOf(String? classes) {
switch (classes) {
case 'mews-warm':
return MewsVariant.warm;
case 'mews-cool':
return MewsVariant.cool;
case 'mews-green':
return MewsVariant.green;
case 'mews-mono':
return MewsVariant.mono;
default:
return MewsVariant.plain;
}
}
List<MewsEntry> _entriesOf(XmlElement body, String pageUrl) {
final entries = <MewsEntry>[];
for (final ul in body.descendants.whereType<XmlElement>().where((e) =>
e.name.qualified == 'ul' || e.name.qualified == 'ol')) {
for (final li in ul.findElements('li')) {
final link = li.children.whereType<XmlElement>().firstOrNull;
if (link == null || link.name.qualified != 'a') continue;
final match = _entryDatePattern.firstMatch(link.innerText.trim());
if (match == null) continue;
final date = DateTime.tryParse(match.group(1)!);
if (date == null) continue;
entries.add(MewsEntry(
url: Uri.parse(pageUrl).resolve(link.getAttribute('href') ?? '').toString(),
date: date,
title: match.group(2)!.trim(),
));
}
}
entries.sort((a, b) => b.date.compareTo(a.date));
return entries;
}
// Blocks --------------------------------------------------------------------
/// Elements whose whole subtree is not content and must never reach the model.
const _droppedElements = {'script', 'style', 'object', 'meta', 'link', 'head'};
void _appendBlocks(XmlElement element, String pageUrl, List<MewsBlock> out) {
switch (element.name.qualified) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
out.add(HeadingBlock(int.parse(element.name.qualified[1]), _inline(element, pageUrl)));
case 'p':
final embedded = <MewsBlock>[];
out.add(ParagraphBlock(_inline(element, pageUrl, embedded)));
out.addAll(embedded);
case 'blockquote':
final children = <MewsBlock>[];
for (final child in element.children.whereType<XmlElement>()) {
_appendBlocks(child, pageUrl, children);
}
out.add(QuoteBlock(children));
case 'pre':
out.add(PreBlock(element.innerText));
case 'address':
final embedded = <MewsBlock>[];
out.add(AddressBlock(_inline(element, pageUrl, embedded)));
out.addAll(embedded);
case 'hr':
out.add(const RuleBlock());
case 'ul':
case 'ol':
out.add(_listBlock(element, pageUrl));
case 'dl':
out.add(_definitionBlock(element, pageUrl));
case 'table':
out.add(_tableBlock(element, pageUrl));
case 'img':
out.add(_imageBlock(element, pageUrl));
case 'form':
out.add(_formBlock(element, pageUrl));
case 'fieldset':
for (final child in element.children.whereType<XmlElement>()) {
_appendBlocks(child, pageUrl, out);
}
default:
// Unknown or dropped wrappers: keep the readable content, drop the rest.
if (!_droppedElements.contains(element.name.qualified)) {
for (final child in element.children.whereType<XmlElement>()) {
_appendBlocks(child, pageUrl, out);
}
}
}
}
ListBlock _listBlock(XmlElement element, String pageUrl) {
final items = <MewsListItem>[];
for (final li in element.findElements('li')) {
final blocks = <MewsBlock>[];
for (final child in li.children.whereType<XmlElement>()) {
_appendBlocks(child, pageUrl, blocks);
}
items.add(MewsListItem(blocks));
}
return ListBlock(
ordered: element.name.qualified == 'ol',
start: int.tryParse(element.getAttribute('start') ?? '') ?? 1,
items: items,
);
}
DefinitionListBlock _definitionBlock(XmlElement element, String pageUrl) {
final items = <DefinitionItem>[];
List<MewsInline>? term;
for (final child in element.children.whereType<XmlElement>()) {
switch (child.name.qualified) {
case 'dt':
term = _inline(child, pageUrl);
case 'dd':
items.add(DefinitionItem(term ?? const [], _inline(child, pageUrl)));
term = null;
}
}
return DefinitionListBlock(items);
}
TableBlock _tableBlock(XmlElement element, String pageUrl) {
final rows = <MewsTableRow>[];
for (final row in element.descendants
.whereType<XmlElement>()
.where((e) => e.name.qualified == 'tr')) {
final cells = <MewsTableCell>[];
for (final cell in row.children.whereType<XmlElement>()) {
final isHeader = cell.name.qualified == 'th';
if (!isHeader && cell.name.qualified != 'td') continue;
cells.add(MewsTableCell(
header: isHeader,
inline: _inline(cell, pageUrl),
colspan: int.tryParse(cell.getAttribute('colspan') ?? '') ?? 1,
rowspan: int.tryParse(cell.getAttribute('rowspan') ?? '') ?? 1,
));
}
rows.add(MewsTableRow(cells));
}
return TableBlock(
rows: rows,
caption: element.findElements('caption').firstOrNull?.innerText.trim(),
summary: element.getAttribute('summary'),
);
}
/// An image loads only from the page's own registered domain, never as a
/// data: URI (spec 4.2). Anything else shows its alt text instead.
ImageBlock _imageBlock(XmlElement element, String pageUrl) {
final src = element.getAttribute('src') ?? '';
final alt = element.getAttribute('alt') ?? '';
final resolved = Uri.tryParse(pageUrl)?.resolve(src);
final allowed = resolved != null &&
!resolved.scheme.startsWith('data') &&
_sameSite(resolved, Uri.parse(pageUrl));
return ImageBlock(src: resolved?.toString() ?? src, alt: alt, allowed: allowed);
}
/// Rough registrable-domain match: the last two labels. Fine for the small
/// web; a public-suffix list is out of scope for v1.
bool _sameSite(Uri image, Uri page) {
String? domain(Uri uri) {
final host = uri.host;
if (host.isEmpty) return null;
final labels = host.split('.');
return labels.length <= 2 ? host : labels.sublist(labels.length - 2).join('.');
}
final imageDomain = domain(image);
return imageDomain != null && imageDomain == domain(page);
}
FormBlock _formBlock(XmlElement element, String pageUrl) {
final labels = <String, String>{};
final fields = <MewsFormField>[];
for (final child in element.descendants.whereType<XmlElement>()) {
switch (child.name.qualified) {
case 'label':
final text = child.innerText.trim();
final forId = child.getAttribute('for');
final controlId = child.children
.whereType<XmlElement>()
.map((e) => e.getAttribute('id'))
.firstOrNull;
if (forId != null) {
labels[forId] = text;
} else if (controlId != null) {
labels[controlId] = text;
}
case 'input':
fields.add(_inputField(child, labels[child.getAttribute('id')]));
case 'select':
final options = <MewsSelectOption>[];
for (final option in child.descendants.whereType<XmlElement>().where(
(e) => e.name.qualified == 'option')) {
options.add(MewsSelectOption(
value: option.getAttribute('value') ?? option.innerText.trim(),
selected: option.getAttribute('selected') != null,
));
}
fields.add(MewsSelectField(
name: child.getAttribute('name') ?? '',
options: options,
label: labels[child.getAttribute('id')],
));
case 'textarea':
fields.add(MewsTextAreaField(
name: child.getAttribute('name') ?? '',
value: child.innerText,
rows: int.tryParse(child.getAttribute('rows') ?? '') ?? 3,
label: labels[child.getAttribute('id')],
));
}
}
String? submitLabel;
for (final field in fields.whereType<MewsSubmitField>()) {
submitLabel ??= field.label;
}
final action = element.getAttribute('action') ?? pageUrl;
return FormBlock(
action: Uri.parse(pageUrl).resolve(action).toString(),
method: (element.getAttribute('method') ?? 'get').toLowerCase(),
fields: fields,
submitLabel: submitLabel,
);
}
MewsFormField _inputField(XmlElement element, String? label) {
final name = element.getAttribute('name') ?? '';
final value = element.getAttribute('value') ?? '';
final checked = element.getAttribute('checked') != null;
switch (element.getAttribute('type') ?? 'text') {
case 'password':
return MewsTextField(name: name, password: true, value: value, label: label);
case 'hidden':
return MewsHiddenField(name: name, value: value);
case 'checkbox':
return MewsCheckboxField(name: name, checked: checked, value: value.isEmpty ? 'on' : value, label: label);
case 'radio':
return MewsRadioField(name: name, checked: checked, value: value, label: label);
case 'submit':
return MewsSubmitField(reset: false, label: value.isEmpty ? null : value, name: name);
case 'reset':
return MewsSubmitField(reset: true, label: value.isEmpty ? null : value, name: name);
default:
return MewsTextField(
name: name,
password: false,
value: value,
maxLength: int.tryParse(element.getAttribute('maxlength') ?? ''),
inputMode: element.getAttribute('inputmode'),
label: label,
);
}
}
// Inline ---------------------------------------------------------------------
List<MewsInline> _inline(XmlElement element, String pageUrl, [List<MewsBlock>? embedded]) {
final out = <MewsInline>[];
for (final child in element.children) {
_appendInline(child, pageUrl, out, embedded);
}
return out;
}
void _appendInline(XmlNode node, String pageUrl, List<MewsInline> out,
[List<MewsBlock>? embedded]) {
if (node is XmlText) {
final text = node.value;
if (text.isNotEmpty) out.add(MewsText(text));
return;
}
if (node is! XmlElement) return;
switch (node.name.qualified) {
case 'a':
final href = node.getAttribute('href') ?? '';
out.add(MewsLink(Uri.parse(pageUrl).resolve(href).toString(), _inline(node, pageUrl, embedded)));
case 'br':
out.add(const MewsLineBreak());
case 'em':
case 'i':
case 'cite':
case 'abbr':
out.add(MewsEmphasis(EmphasisStyle.emphasis, _inline(node, pageUrl, embedded)));
case 'strong':
case 'b':
out.add(MewsEmphasis(EmphasisStyle.strong, _inline(node, pageUrl, embedded)));
case 'code':
out.add(MewsEmphasis(EmphasisStyle.code, _inline(node, pageUrl, embedded)));
case 'kbd':
out.add(MewsEmphasis(EmphasisStyle.sample, _inline(node, pageUrl, embedded)));
case 'samp':
out.add(MewsEmphasis(EmphasisStyle.sample, _inline(node, pageUrl, embedded)));
case 'var':
out.add(MewsEmphasis(EmphasisStyle.variable, _inline(node, pageUrl, embedded)));
case 'dfn':
out.add(MewsEmphasis(EmphasisStyle.term, _inline(node, pageUrl, embedded)));
case 'q':
out.add(MewsEmphasis(EmphasisStyle.quote, _inline(node, pageUrl, embedded)));
case 'img':
// Every image is shown in its own block (spec 4.2), so an inline image
// is lifted out of the paragraph that contains it.
embedded?.add(_imageBlock(node, pageUrl));
default:
if (!_droppedElements.contains(node.name.qualified)) {
for (final child in node.children) {
_appendInline(child, pageUrl, out, embedded);
}
}
}
}
/// Recovers readable text from a page that failed XML parsing, for the "show
/// its text" escape hatch (spec 8.1). Best effort by design.
String mewsPlainText(String source) {
final withoutBlocks = source.replaceAll(RegExp(r'<(script|style)[\s\S]*?</\1\s*>', caseSensitive: false), '');
final stripped = withoutBlocks.replaceAll(RegExp(r'<[^>]+>'), ' ');
return stripped
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}

View file

@ -0,0 +1,100 @@
import 'package:actors/actors.dart';
import 'package:flutter/material.dart';
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 '../database/database.dart';
import '../l10n/strings.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})
: db = database ?? MewsDatabase(),
fetch = Actor.create(FetchHandler.new) {
browser = BrowserController(db: db, fetch: fetch);
settings = ReaderSettingsController(db);
library = LibraryController(db);
}
final MewsDatabase db;
final Actor<FetchCommand, FetchResult> fetch;
late final BrowserController browser;
late final ReaderSettingsController settings;
late final LibraryController library;
Future<void> start() async {
await settings.load();
await library.load();
await browser.restoreLastSession();
}
}
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: const _ThemedApp(),
);
}
}
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,
};
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),
),
home: const ReaderScreen(),
);
});
}
}

View file

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../app.dart';
/// The single-field address bar at the top of the reader. Submitting navigates;
/// the field shows the current page's URL whenever the reader is not typing.
class AddressBar extends StatefulWidget {
const AddressBar({super.key});
@override
State<AddressBar> createState() => _AddressBarState();
}
class _AddressBarState extends State<AddressBar> {
final _controller = TextEditingController();
final _focus = FocusNode();
bool _focused = false;
@override
void initState() {
super.initState();
_focus.addListener(() {
if (_focus.hasFocus != _focused) {
setState(() => _focused = _focus.hasFocus);
if (_focused) {
_controller.selection =
TextSelection(baseOffset: 0, extentOffset: _controller.text.length);
}
}
});
}
@override
void dispose() {
_focus.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
final strings = AppScope.of(context).strings;
return SignalBuilder(builder: (context) {
final url = deps.browser.currentPage.value?.url;
if (url != null && !_focused) {
_controller.text = url;
}
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _controller,
focusNode: _focus,
autofocus: false,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (value) {
_focus.unfocus();
deps.browser.open(value);
},
decoration: InputDecoration(
hintText: strings.t('reader-address-placeholder'),
labelText: strings.t('reader-address-label'),
prefixIcon: IconButton(
icon: const Icon(Icons.refresh),
tooltip: strings.t('reader-reload'),
onPressed: () => deps.browser.reload(),
),
isDense: true,
border: const OutlineInputBorder(),
),
),
);
});
}
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// The floating control that appears when the reader scrolls, opening the
/// reader settings bottom sheet.
class Fob extends StatelessWidget {
const Fob({required this.visible, required this.label, required this.onTap, super.key});
final bool visible;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: IgnorePointer(
ignoring: !visible,
child: Padding(
padding: const EdgeInsets.all(16),
child: Semantics(
label: label,
button: true,
child: FloatingActionButton(
onPressed: onTap,
child: const Icon(Icons.tune),
),
),
),
),
);
}
}

View file

@ -0,0 +1,482 @@
import 'dart:ui' as ui show TextDirection;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'palettes.dart';
/// Renders a parsed [MewsPage] with the client's own presentation, following
/// the measurements of the default stylesheet mews-0.1.css: a 40em text
/// column, 1.5 line height, and the block margins authors expect.
class MewsPageView extends StatefulWidget {
const MewsPageView({
required this.page,
required this.palette,
required this.fontFamily,
required this.fontScale,
required this.visitedUrls,
required this.onLinkTap,
required this.onFormSubmit,
this.scrollController,
super.key,
});
final MewsPage page;
final MewsPalette palette;
final String fontFamily;
final double fontScale;
final Set<String> visitedUrls;
final void Function(String url) onLinkTap;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
final ScrollController? scrollController;
@override
State<MewsPageView> createState() => _MewsPageViewState();
}
class _MewsPageViewState extends State<MewsPageView> {
final _recognizers = <MewsLink, TapGestureRecognizer>{};
@override
void didUpdateWidget(MewsPageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.page != widget.page) {
_disposeRecognizers();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers.values) {
recognizer.dispose();
}
_recognizers.clear();
}
@override
Widget build(BuildContext context) {
final strings = StringsScope.of(context);
final base = 17.0 * widget.fontScale; // body font-size: 106%
final palette = widget.palette;
final blocks = [
if (!widget.page.isMews)
_notice(strings.t('error-non-mews'), base, palette),
for (final block in widget.page.body) _block(context, block, base),
];
return Directionality(
textDirection: widget.page.direction == TextDirection.rtl
? ui.TextDirection.rtl
: ui.TextDirection.ltr,
child: ColoredBox(
color: palette.background,
child: ListView(
controller: widget.scrollController,
padding: EdgeInsets.all(base),
children: [
Center(
child: ConstrainedBox(
// max-width: 40em
constraints: BoxConstraints(maxWidth: 40 * base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: blocks,
),
),
),
],
),
),
);
}
Widget _notice(String text, double base, MewsPalette palette) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text(
text,
style: TextStyle(color: palette.foreground.withValues(alpha: 0.7), fontSize: base * 0.85),
),
);
// Blocks -------------------------------------------------------------------
Widget _block(BuildContext context, MewsBlock block, double base) {
final palette = widget.palette;
final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5);
return switch (block) {
HeadingBlock(:final level, :final inline) => Padding(
padding: EdgeInsets.only(
top: level == 1 ? base * 0.5 : base * 1.6,
bottom: base * 0.5,
),
child: Text.rich(
TextSpan(children: _inline(inline)),
style: foreground.copyWith(
fontWeight: FontWeight.bold,
height: 1.25,
fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1],
),
),
),
ParagraphBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text.rich(TextSpan(children: _inline(inline)), style: foreground),
),
AddressBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text.rich(
TextSpan(children: _inline(inline)),
style: foreground.copyWith(fontStyle: FontStyle.italic),
),
),
QuoteBlock(:final children) => Container(
margin: EdgeInsets.only(bottom: base),
decoration: BoxDecoration(
border: BorderDirectional(start: BorderSide(color: palette.border, width: 3)),
),
padding: EdgeInsetsDirectional.only(start: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final child in children) _block(context, child, base)],
),
),
PreBlock(:final text) => Container(
margin: EdgeInsets.only(bottom: base),
color: palette.preBackground,
padding: EdgeInsets.all(base * 0.75),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
text,
style: foreground.copyWith(
fontFamily: 'monospace',
fontSize: base * 0.9,
),
),
),
),
RuleBlock() => Padding(
padding: EdgeInsets.symmetric(vertical: base * 2),
child: Divider(color: palette.border, height: 1),
),
ListBlock(:final ordered, :final start, :final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < items.length; i++)
Padding(
padding: EdgeInsets.only(bottom: base * 0.25),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: base * 1.5,
child: Text(
ordered ? '${start + i}.' : '\u2022',
style: foreground,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in items[i].blocks) _block(context, child, base),
],
),
),
],
),
),
],
),
),
DefinitionListBlock(:final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final item in items) ...[
Text.rich(
TextSpan(children: _inline(item.term)),
style: foreground.copyWith(fontWeight: FontWeight.bold),
),
Padding(
padding: EdgeInsetsDirectional.only(start: base * 1.5, bottom: base * 0.5),
child: Text.rich(TextSpan(children: _inline(item.definition)), style: foreground),
),
],
],
),
),
TableBlock(:final rows, :final caption) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (caption != null)
Text(caption,
style: foreground.copyWith(fontWeight: FontWeight.bold)),
Table(
border: TableBorder.all(color: palette.border),
defaultVerticalAlignment: TableCellVerticalAlignment.top,
children: [
for (final row in rows)
TableRow(
children: [
for (final cell in row.cells)
TableCell(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: base * 0.6,
vertical: base * 0.3,
),
child: Text.rich(
TextSpan(children: _inline(cell.inline)),
style: foreground.copyWith(
fontWeight: cell.header ? FontWeight.bold : null,
),
),
),
),
],
),
],
),
],
),
),
ImageBlock(:final src, :final alt, :final allowed) => Padding(
padding: EdgeInsets.only(bottom: base),
child: allowed
? Image.network(
src,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
)
: _imageFallback(alt, base),
),
FormBlock() => Padding(
padding: EdgeInsets.only(bottom: base),
child: _MewsForm(
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
),
),
};
}
Widget _imageFallback(String alt, double base, {bool unavailable = false}) {
final palette = widget.palette;
final strings = StringsScope.of(context);
final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked';
final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt';
return Container(
width: double.infinity,
padding: EdgeInsets.all(base * 0.75),
decoration: BoxDecoration(
border: Border.all(color: palette.border),
color: palette.preBackground,
),
child: Text(
message,
style: TextStyle(color: palette.foreground, fontSize: base * 0.9),
),
);
}
/// Builds the span tree. [linkRecognizer] carries the enclosing link's
/// recognizer down to every glyph-owning span: hit-testing resolves to leaf
/// spans only, so a recognizer on a parent span alone never fires.
List<InlineSpan> _inline(List<MewsInline> inline, [TapGestureRecognizer? linkRecognizer]) {
final palette = widget.palette;
return [
for (final node in inline)
switch (node) {
MewsText(:final text) => TextSpan(text: text, recognizer: linkRecognizer),
MewsLineBreak() => const TextSpan(text: '\n'),
MewsEmphasis(:final style, :final children) => TextSpan(
style: switch (style) {
EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold),
EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic),
_ => const TextStyle(fontFamily: 'monospace'),
},
children: _inline(children, linkRecognizer)),
MewsLink(:final href, :final children) => TextSpan(
style: TextStyle(
color: widget.visitedUrls.contains(href) ? palette.visited : palette.link,
decoration: TextDecoration.underline,
),
recognizer: _recognizer(node),
children: _inline(children, _recognizer(node))),
},
];
}
TapGestureRecognizer _recognizer(MewsLink link) {
return _recognizers.putIfAbsent(link, () {
return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href);
});
}
}
/// A form rendered from the page model. Submitting sends the field values to
/// the browser controller; reset restores the author's initial values.
class _MewsForm extends StatefulWidget {
const _MewsForm({
required this.form,
required this.foreground,
required this.onFormSubmit,
});
final FormBlock form;
final TextStyle foreground;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
@override
State<_MewsForm> createState() => _MewsFormState();
}
class _MewsFormState extends State<_MewsForm> {
final _controllers = <String, TextEditingController>{};
final _values = <String, String>{};
@override
void dispose() {
for (final controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final strings = StringsScope.of(context);
final form = widget.form;
final style = widget.foreground;
return Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final field in form.fields) _field(field, style),
for (final field in form.fields.whereType<MewsSubmitField>())
Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton(
onPressed: () => field.reset ? _reset(form) : _submit(form),
child: Text(
field.label ??
(field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')),
),
),
),
],
),
);
}
Widget _field(MewsFormField field, TextStyle style) {
Widget control = switch (field) {
MewsTextField(:final name, :final password, :final value, :final maxLength) => TextFormField(
controller: _controllers.putIfAbsent(
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
obscureText: password,
maxLength: maxLength,
style: style,
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsTextAreaField(:final name, :final value, :final rows) => TextFormField(
controller: _controllers.putIfAbsent(
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
minLines: rows,
maxLines: rows,
style: style,
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsCheckboxField(:final name, :final checked) => CheckboxListTile(
title: field.label == null ? null : Text(field.label!, style: style),
value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on',
onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''),
),
MewsRadioField(:final name, :final value, :final checked) => RadioGroup<String>(
groupValue: _values.putIfAbsent(name, () => checked ? value : ''),
onChanged: (value) => setState(() => _values[name] = value ?? ''),
child: RadioListTile<String>(
value: value,
title: field.label == null ? null : Text(field.label!, style: style),
),
),
MewsSelectField(:final name, :final options) => options.isEmpty
? const SizedBox.shrink()
: DropdownButtonFormField<String>(
initialValue: _values.putIfAbsent(
name,
() => options.firstWhere((o) => o.selected, orElse: () => options.first).value),
items: [
for (final option in options) DropdownMenuItem(value: option.value, child: Text(option.value)),
],
onChanged: (value) => _values[name] = value ?? '',
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsSubmitField() => const SizedBox.shrink(),
MewsHiddenField() => const SizedBox.shrink(),
};
return control;
}
void _submit(FormBlock form) {
final values = <String, String>{};
for (final field in form.fields) {
switch (field) {
case MewsTextField(:final name):
values[name] = _controllers[name]?.text ?? '';
case MewsTextAreaField(:final name):
values[name] = _controllers[name]?.text ?? '';
case MewsCheckboxField(:final name, :final value):
if (_values[name] == 'on') values[name] = value;
case MewsRadioField(:final name, :final value):
if (_values[name] == value) values[name] = value;
case MewsSelectField(:final name):
values[name] = _values[name] ?? '';
case MewsHiddenField(:final name, :final value):
values[name] = value;
case MewsSubmitField():
break;
}
}
widget.onFormSubmit(form, values);
}
void _reset(FormBlock form) {
setState(() {
_values.clear();
for (final controller in _controllers.values) {
controller.clear();
}
// Restore the author's initial text field values.
for (final field in form.fields) {
switch (field) {
case MewsTextField(:final name, :final value):
_controllers[name]?.text = value;
case MewsTextAreaField(:final name, :final value):
_controllers[name]?.text = value;
default:
break;
}
}
});
}
}

View file

@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import '../../mews/document.dart';
/// The client's own presentation, mirroring the palettes of the default
/// stylesheet mews-0.1.css (spec 5.1). Mews clients ignore author CSS and use
/// their own, so these colors are the single visual source for page content.
class MewsPalette {
const MewsPalette({
required this.background,
required this.foreground,
required this.link,
required this.visited,
required this.border,
required this.preBackground,
});
final Color background;
final Color foreground;
final Color link;
final Color visited;
final Color border;
final Color preBackground;
}
const _plainLight = MewsPalette(
background: Color(0xfffaf8f3),
foreground: Color(0xff1f1f1f),
link: Color(0xff1a4f9c),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _plainDark = MewsPalette(
background: Color(0xff18191b),
foreground: Color(0xffe3e1dc),
link: Color(0xff8fb8f5),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _warmLight = MewsPalette(
background: Color(0xfffbf4e8),
foreground: Color(0xff2a2420),
link: Color(0xff8a3d14),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _warmDark = MewsPalette(
background: Color(0xff1d1916),
foreground: Color(0xffe8dfd3),
link: Color(0xfff0b48a),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _coolLight = MewsPalette(
background: Color(0xfff3f6fa),
foreground: Color(0xff1c2430),
link: Color(0xff1a4f9c),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _coolDark = MewsPalette(
background: Color(0xff161a20),
foreground: Color(0xffdde4ee),
link: Color(0xff8fb8f5),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _greenLight = MewsPalette(
background: Color(0xfff2f6ef),
foreground: Color(0xff1f2a1f),
link: Color(0xff24502a),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _greenDark = MewsPalette(
background: Color(0xff161b16),
foreground: Color(0xffdbe6d8),
link: Color(0xff9fd39a),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _monoLight = MewsPalette(
background: Color(0xffe8ebe1),
foreground: Color(0xff1e2219),
link: Color(0xff1e2219),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _monoDark = MewsPalette(
background: Color(0xff1b1d18),
foreground: Color(0xffc8d0b8),
link: Color(0xffc8d0b8),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
/// 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);
return switch ((variant, dark)) {
(MewsVariant.warm, false) => _warmLight,
(MewsVariant.warm, true) => _warmDark,
(MewsVariant.cool, false) => _coolLight,
(MewsVariant.cool, true) => _coolDark,
(MewsVariant.green, false) => _greenLight,
(MewsVariant.green, true) => _greenDark,
(MewsVariant.mono, false) => _monoLight,
(MewsVariant.mono, true) => _monoDark,
(_, false) => _plainLight,
(_, true) => _plainDark,
};
}
MewsVariant pageVariantFromName(String name) => switch (name) {
'warm' => MewsVariant.warm,
'cool' => MewsVariant.cool,
'green' => MewsVariant.green,
'mono' => MewsVariant.mono,
_ => MewsVariant.plain,
};

View file

@ -0,0 +1,15 @@
/// The bundled typefaces the reader can choose from (spec 8.2). Adding a font
/// is one TTF in assets/fonts, one entry in pubspec.yaml and one entry here.
class ReaderFont {
const ReaderFont(this.family, this.labelId);
/// The pubspec font family name.
final String family;
/// Message ID of its display name in locales/en-US/main.ftl.
final String labelId;
}
const readerFonts = [
ReaderFont('Atkinson Hyperlegible', 'settings-font-atkinson'),
];

View file

@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../../application/controllers/browser_controller.dart'
show BrokenPage, LoadFailed;
import '../../mews/document.dart';
import '../../mews/parser.dart' show mewsPlainText;
import '../app.dart';
import 'address_bar.dart';
import 'fob.dart';
import 'mews_page_view.dart';
import 'palettes.dart';
import 'settings_sheet.dart';
/// The whole v1 UI: an address bar that hides on scroll, the page reader with
/// swipe back/forward, a settings FOB that appears on scroll, and error
/// states that never guess.
class ReaderScreen extends StatefulWidget {
const ReaderScreen({super.key});
@override
State<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends State<ReaderScreen> {
final _scroll = ScrollController();
bool _barVisible = true;
bool _fobVisible = false;
bool _showBrokenText = false;
final _reportedNewPosts = <String>{};
final _checkedSubscriptions = <String>{};
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
super.dispose();
}
void _onScroll() {
if (!_scroll.hasClients) return;
final position = _scroll.position;
// The bar hides when the reader scrolls down and comes back on any
// upward scroll; it is always visible at the top of the page.
final wantBar = position.pixels <= 0 ||
position.userScrollDirection == ScrollDirection.forward;
// The FOB appears once the page is scrolled, and stays reachable on pages
// that fit without scrolling.
final contentScrolls = position.maxScrollExtent > 0;
final wantFob = contentScrolls ? position.pixels > 16 : true;
if (wantBar != _barVisible || wantFob != _fobVisible) {
setState(() {
_barVisible = wantBar;
_fobVisible = wantFob;
});
}
}
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
final deps = scope.deps;
final strings = scope.strings;
return Scaffold(
body: Column(
children: [
AnimatedSlide(
offset: _barVisible ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _barVisible
? const AddressBar()
: const SizedBox(width: double.infinity),
),
),
Expanded(
child: SignalBuilder(builder: (context) {
final browser = deps.browser;
final page = browser.currentPage.value;
return GestureDetector(
behavior: HitTestBehavior.deferToChild,
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity ?? 0;
// Swipe right goes back, swipe left goes forward.
if (velocity > 300) {
browser.back();
} else if (velocity < -300) {
browser.forward();
}
},
child: Stack(
children: [
Positioned.fill(child: _body(context, page)),
Positioned(
right: 0,
bottom: 0,
child: Fob(
visible: _fobVisible,
label: strings.t('reader-fob-label'),
onTap: () => showReaderSettingsSheet(context),
),
),
if (browser.loading.value)
const Positioned(
top: 8,
left: 0,
right: 0,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
);
}),
),
],
),
);
}
Widget _body(BuildContext context, MewsPage? page) {
final deps = AppScope.of(context).deps;
if (page == null) {
return SignalBuilder(builder: (context) {
final error = deps.browser.error.value;
return switch (error) {
BrokenPage(:final source) => _errorPage(context, source),
LoadFailed() => _errorPage(context, null),
null => _emptyPage(context),
};
});
}
_checkSubscription(context, page);
_showBrokenText = false;
return SignalBuilder(builder: (context) {
final settings = deps.settings;
final font = settings.font.value;
final fontScale = settings.fontScale.value;
final variant = settings.variant.value;
final visited = deps.browser.visitedUrls.value;
_maybeReportNewPosts(context, page);
final palette = paletteFor(
pageVariant: page.variant,
readerVariant: variant,
dark: Theme.of(context).brightness == Brightness.dark,
);
return MewsPageView(
key: ValueKey(page.url),
page: page,
palette: palette,
fontFamily: font,
fontScale: fontScale,
visitedUrls: visited,
onLinkTap: (url) => deps.browser.open(url),
onFormSubmit: (form, values) =>
deps.browser.submitForm(form.action, form.method, values),
scrollController: _scroll,
);
});
}
Widget _emptyPage(BuildContext context) {
// No page yet: the address bar is the whole UI.
return const SizedBox.expand();
}
Widget _errorPage(BuildContext context, String? brokenSource) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
Widget content;
if (brokenSource == null) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('error-load-failed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.reload(),
child: Text(strings.t('error-try-again')),
),
],
);
} else if (_showBrokenText) {
content = Column(
children: [
Text(strings.t('error-broken-page-text-title'),
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Expanded(child: SingleChildScrollView(child: Text(mewsPlainText(brokenSource)))),
],
);
} else {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('error-broken-page'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => setState(() => _showBrokenText = true),
child: Text(strings.t('error-broken-page-show-text')),
),
],
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: content,
),
);
}
/// Revalidates a subscribed page once per page load so new entries surface
/// (spec 6.1). The library controller no-ops for unsubscribed pages.
void _checkSubscription(BuildContext context, MewsPage page) {
if (_checkedSubscriptions.contains(page.url)) return;
_checkedSubscriptions.add(page.url);
final deps = AppScope.of(context).deps;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) deps.library.refreshSubscription(page);
});
}
/// Shows the new-posts snackbar once per site after the refresh reports
/// entries the reader has not seen.
void _maybeReportNewPosts(BuildContext context, MewsPage page) {
final scope = AppScope.of(context);
final fresh = scope.deps.library.newEntries.value[page.url];
if (fresh == null || fresh.isEmpty || _reportedNewPosts.contains(page.url)) return;
_reportedNewPosts.add(page.url);
final strings = scope.strings;
final message = fresh.length == 1
? strings.t('new-posts-one')
: strings.t('new-posts-many', {'count': fresh.length});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
});
}
}

View file

@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../../application/controllers/reader_settings_controller.dart';
import '../app.dart';
import 'reader_fonts.dart';
/// Opens the reader settings bottom sheet: fonts, text size, theme, colors,
/// bookmark and subscription for the current page.
void showReaderSettingsSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (context) => const SettingsSheet(),
);
}
class SettingsSheet extends StatelessWidget {
const SettingsSheet({super.key});
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
final deps = scope.deps;
final strings = scope.strings;
return SignalBuilder(builder: (context) {
final settings = deps.settings;
final library = deps.library;
final page = deps.browser.currentPage.value;
final url = page?.url;
final percent = (settings.fontScale.value * 100).round();
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
strings.t('settings-sheet-title'),
style: Theme.of(context).textTheme.titleMedium,
),
),
_heading(context, strings.t('settings-font-heading')),
RadioGroup<String>(
groupValue: settings.font.value,
onChanged: (value) => settings.setFont(value!),
child: Column(
children: [
for (final font in readerFonts)
RadioListTile<String>(
value: font.family,
title: Text(strings.t(font.labelId)),
),
],
),
),
_heading(context, strings.t('settings-size-heading')),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: strings.t('settings-size-decrease'),
onPressed: settings.fontScale.value > ReaderSettingsController.minScale
? settings.decreaseSize
: null,
icon: const Icon(Icons.remove),
),
SizedBox(
width: 56,
child: Text(
'$percent%',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
tooltip: strings.t('settings-size-increase'),
onPressed: settings.fontScale.value < ReaderSettingsController.maxScale
? settings.increaseSize
: null,
icon: const Icon(Icons.add),
),
],
),
_heading(context, strings.t('settings-theme-heading')),
RadioGroup<String>(
groupValue: settings.themeMode.value,
onChanged: (value) => settings.setThemeMode(value!),
child: Column(
children: [
for (final (mode, id) in [
('light', 'settings-theme-light'),
('dark', 'settings-theme-dark'),
('system', 'settings-theme-system'),
])
RadioListTile<String>(
value: mode,
title: Text(strings.t(id)),
),
],
),
),
_heading(context, strings.t('settings-variant-heading')),
RadioGroup<String>(
groupValue: settings.variant.value,
onChanged: (value) => settings.setVariant(value!),
child: Column(
children: [
for (final (variant, id) in [
('site', 'settings-variant-site'),
('warm', 'settings-variant-warm'),
('cool', 'settings-variant-cool'),
('green', 'settings-variant-green'),
('mono', 'settings-variant-mono'),
])
RadioListTile<String>(
value: variant,
title: Text(strings.t(id)),
),
],
),
),
const Divider(),
if (url != null) ...[
ListTile(
leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border),
title: Text(
library.isBookmarked(url)
? strings.t('settings-bookmark-remove')
: strings.t('settings-bookmark-add'),
),
onTap: () => library.toggleBookmark(url, page?.title),
),
ListTile(
leading: Icon(library.isSubscribed(url)
? Icons.notifications_active
: Icons.notifications_outlined),
title: Text(
library.isSubscribed(url)
? strings.t('settings-unsubscribe')
: strings.t('settings-subscribe'),
),
subtitle: library.isSubscribed(url)
? Text(strings.t('settings-subscribed-notice'))
: null,
onTap: () => library.toggleSubscription(url, page?.title, page?.entries ?? const []),
),
],
],
),
);
});
}
Widget _heading(BuildContext context, String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme.of(context).colorScheme.primary),
),
);
}