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

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

View file

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

View file

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

View file

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