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
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue