the-mews/app/lib/application/controllers/browser_controller.dart

176 lines
5.2 KiB
Dart
Raw Normal View History

import 'dart:async';
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>{});
/// Titles of the stack's neighboring pages, shown while swiping.
final backTitle = signal<String?>(null);
final forwardTitle = signal<String?>(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<void> open(String input) async {
final url = normalizeAddress(input);
if (url.isEmpty) return;
_stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(url);
_stackIndex++;
_updatePeeks();
await _loadCurrent();
}
Future<void> back() async {
if (!canGoBack) return;
_stackIndex--;
_updatePeeks();
await _loadCurrent();
}
Future<void> forward() async {
if (!canGoForward) return;
_stackIndex++;
_updatePeeks();
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++;
_updatePeeks();
_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;
_updatePeeks();
await _loadCurrent();
}
/// Refreshes the neighboring-page titles from the cache; a page without a
/// cached title falls back to its URL.
void _updatePeeks() {
Future<String?> peek(int index) async {
if (index < 0 || index >= _stack.length) return null;
final url = _stack[index];
return (await _db.cachedPage(url))?.title ?? url;
}
unawaited(peek(_stackIndex - 1).then((t) => backTitle.value = t));
unawaited(peek(_stackIndex + 1).then((t) => forwardTitle.value = t));
}
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);
}
}