feat: build v1 Mews client: reader UI, gestures, Drift store, fetch actor
This commit is contained in:
parent
d5a0fcbf48
commit
01a36e8aee
56 changed files with 7287 additions and 2 deletions
160
app/lib/application/actors/fetch_actor.dart
Normal file
160
app/lib/application/actors/fetch_actor.dart
Normal 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();
|
||||
}
|
||||
151
app/lib/application/controllers/browser_controller.dart
Normal file
151
app/lib/application/controllers/browser_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
135
app/lib/application/controllers/reader_settings_controller.dart
Normal file
135
app/lib/application/controllers/reader_settings_controller.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
157
app/lib/database/database.dart
Normal file
157
app/lib/database/database.dart
Normal 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);
|
||||
}
|
||||
2760
app/lib/database/database.g.dart
Normal file
2760
app/lib/database/database.g.dart
Normal file
File diff suppressed because it is too large
Load diff
39
app/lib/l10n/strings.dart
Normal file
39
app/lib/l10n/strings.dart
Normal 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
16
app/lib/main.dart
Normal 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
291
app/lib/mews/document.dart
Normal 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
434
app/lib/mews/parser.dart
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
}
|
||||
100
app/lib/presentation/app.dart
Normal file
100
app/lib/presentation/app.dart
Normal 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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
80
app/lib/presentation/reader/address_bar.dart
Normal file
80
app/lib/presentation/reader/address_bar.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
34
app/lib/presentation/reader/fob.dart
Normal file
34
app/lib/presentation/reader/fob.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
482
app/lib/presentation/reader/mews_page_view.dart
Normal file
482
app/lib/presentation/reader/mews_page_view.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
140
app/lib/presentation/reader/palettes.dart
Normal file
140
app/lib/presentation/reader/palettes.dart
Normal 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,
|
||||
};
|
||||
15
app/lib/presentation/reader/reader_fonts.dart
Normal file
15
app/lib/presentation/reader/reader_fonts.dart
Normal 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'),
|
||||
];
|
||||
261
app/lib/presentation/reader/reader_screen.dart
Normal file
261
app/lib/presentation/reader/reader_screen.dart
Normal 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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
169
app/lib/presentation/reader/settings_sheet.dart
Normal file
169
app/lib/presentation/reader/settings_sheet.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue