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 fetch}) : _db = db, _fetch = fetch; final MewsDatabase _db; final Actor _fetch; /// The stack of visited pages; the reader is at [_stackIndex]. final List _stack = []; int _stackIndex = -1; final currentPage = signal(null); final loading = signal(false); final error = signal(null); /// Pages opened this session, so links can show the visited color. final visitedUrls = signal({}); /// The parsed pages next to the current one in the stack, ready to show /// while swiping between them. final backPage = signal(null); final forwardPage = signal(null); 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 open(String input) async { final url = normalizeAddress(input); if (url.isEmpty) return; _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(url); _stackIndex++; _updateNeighbors(); await _loadCurrent(); } Future back() async { if (!canGoBack) return; _stackIndex--; // The neighbor was parsed for the swipe already; show it right away so // the transition lands on content instead of a blank page while // _loadCurrent revalidates it against the server. _showParsedNeighbor(backPage.value); _updateNeighbors(); await _loadCurrent(); } Future forward() async { if (!canGoForward) return; _stackIndex++; _showParsedNeighbor(forwardPage.value); _updateNeighbors(); await _loadCurrent(); } void _showParsedNeighbor(MewsPage? page) { if (page == null) return; currentPage.value = page; error.value = null; visitedUrls.value = {...visitedUrls.value, page.url}; } Future reload() => _loadCurrent(); Future submitForm(String action, String method, Map 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++; _updateNeighbors(); _show(page); case _: error.value = const LoadFailed(); } } /// Reopens the most recent page from the previous session, if any. Future restoreLastSession() async { final url = await _db.lastVisitedUrl(); if (url == null) return; _stack.add(url); _stackIndex = 0; _updateNeighbors(); await _loadCurrent(); } /// Parses the stack's neighboring pages in the fetch actor so swipes can /// show the real content. Results arriving after another navigation are /// dropped, so rapid navigation never shows a stale neighbor. void _updateNeighbors() { _loadNeighbor(_stackIndex - 1, backPage); _loadNeighbor(_stackIndex + 1, forwardPage); } Future _loadNeighbor(int index, Signal target) async { final expectedIndex = _stackIndex; if (index < 0 || index >= _stack.length) { target.value = null; return; } final url = _stack[index]; final cached = await _db.cachedPage(url); if (_stackIndex != expectedIndex) return; if (cached == null) { target.value = null; return; } final result = await _fetch.send(ParseSource(url: url, source: cached.source)); if (_stackIndex != expectedIndex) return; target.value = result is FetchPageOk ? result.page : null; } Future _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); } }