151 lines
4.4 KiB
Dart
151 lines
4.4 KiB
Dart
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);
|
|
}
|
|
}
|