feat: theme chrome by palette, selectable text, wap fallback, and library pages
This commit is contained in:
parent
c104c2026b
commit
56f3d959fa
12 changed files with 534 additions and 51 deletions
|
|
@ -5,6 +5,7 @@ import 'package:signals/signals.dart';
|
|||
import '../../database/database.dart';
|
||||
import '../../mews/document.dart';
|
||||
import '../actors/fetch_actor.dart';
|
||||
import '../internal_pages.dart';
|
||||
|
||||
/// What went wrong while browsing. The presentation layer maps these to
|
||||
/// user-facing messages; no raw codes cross into the UI.
|
||||
|
|
@ -27,12 +28,17 @@ class BrokenPage extends BrowseError {
|
|||
/// 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;
|
||||
BrowserController({
|
||||
required MewsDatabase db,
|
||||
required Actor<FetchCommand, FetchResult> fetch,
|
||||
InternalPages? internalPages,
|
||||
}) : _db = db,
|
||||
_fetch = fetch,
|
||||
_internalPages = internalPages;
|
||||
|
||||
final MewsDatabase _db;
|
||||
final Actor<FetchCommand, FetchResult> _fetch;
|
||||
final InternalPages? _internalPages;
|
||||
|
||||
/// The stack of visited pages; the reader is at [_stackIndex].
|
||||
final List<String> _stack = [];
|
||||
|
|
@ -54,14 +60,27 @@ class BrowserController {
|
|||
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
|
||||
|
||||
/// Normalizes what the reader typed into an address: bare domains become
|
||||
/// https URLs.
|
||||
/// https URLs; anything already carrying a scheme (mews:, https:, ...) is
|
||||
/// left alone.
|
||||
static String normalizeAddress(String input) {
|
||||
final trimmed = input.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
if (trimmed.contains('://')) return trimmed;
|
||||
if (_schemePattern.hasMatch(trimmed)) return trimmed;
|
||||
return 'https://$trimmed';
|
||||
}
|
||||
|
||||
static final _schemePattern = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*:');
|
||||
|
||||
/// The wap. subdomain equivalent of [url], or null when the host already
|
||||
/// starts with it or the address is not a plain web URL.
|
||||
static String? wapUrlOf(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null;
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
|
||||
if (uri.host.startsWith('wap.')) return null;
|
||||
return uri.replace(host: 'wap.${uri.host}').toString();
|
||||
}
|
||||
|
||||
Future<void> open(String input) async {
|
||||
final url = normalizeAddress(input);
|
||||
if (url.isEmpty) return;
|
||||
|
|
@ -70,6 +89,23 @@ class BrowserController {
|
|||
_stackIndex++;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
|
||||
// Mews sites commonly live on a wap. subdomain; try that before showing
|
||||
// an error when the address as typed does not work out.
|
||||
if (error.value != null && _stack[_stackIndex] == url) {
|
||||
final wap = wapUrlOf(url);
|
||||
if (wap != null) {
|
||||
final firstError = error.value;
|
||||
_stack[_stackIndex] = wap;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
// The address as typed is the closer match; keep its error when the
|
||||
// wap. attempt does not work either.
|
||||
if (error.value != null && firstError != null) {
|
||||
error.value = firstError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> back() async {
|
||||
|
|
@ -142,6 +178,10 @@ class BrowserController {
|
|||
return;
|
||||
}
|
||||
final url = _stack[index];
|
||||
if (_internalPages?.handles(url) ?? false) {
|
||||
target.value = await _internalPages!.pageFor(url);
|
||||
return;
|
||||
}
|
||||
final cached = await _db.cachedPage(url);
|
||||
if (_stackIndex != expectedIndex) return;
|
||||
if (cached == null) {
|
||||
|
|
@ -159,6 +199,22 @@ class BrowserController {
|
|||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// Internal pages are built from the local database, never fetched or
|
||||
// cached; they join the stack like any other page.
|
||||
if (_internalPages?.handles(url) ?? false) {
|
||||
final page = await _internalPages!.pageFor(url);
|
||||
loading.value = false;
|
||||
if (page != null) {
|
||||
currentPage.value = page;
|
||||
error.value = null;
|
||||
visitedUrls.value = {...visitedUrls.value, url};
|
||||
} else {
|
||||
currentPage.value = null;
|
||||
error.value = const LoadFailed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final cached = await _db.cachedPage(url);
|
||||
final result = await _fetch.send(FetchPage(
|
||||
url: url,
|
||||
|
|
|
|||
165
app/lib/application/internal_pages.dart
Normal file
165
app/lib/application/internal_pages.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import '../../database/database.dart';
|
||||
import '../../l10n/strings.dart';
|
||||
import '../../mews/document.dart';
|
||||
import 'controllers/reader_settings_controller.dart'
|
||||
show LibraryController;
|
||||
|
||||
/// Builds the internal History, Bookmarks and Subscriptions pages as real
|
||||
/// [MewsPage]s: they render like any other page and take part in the walk
|
||||
/// history, so swiping and back/forward treat them like visited pages.
|
||||
class InternalPages {
|
||||
InternalPages(
|
||||
{required MewsDatabase db,
|
||||
required LibraryController library,
|
||||
required Strings strings})
|
||||
: _db = db,
|
||||
_library = library,
|
||||
_strings = strings;
|
||||
|
||||
static const historyUrl = 'mews:history';
|
||||
static const bookmarksUrl = 'mews:bookmarks';
|
||||
static const subscriptionsUrl = 'mews:subscriptions';
|
||||
|
||||
final MewsDatabase _db;
|
||||
final LibraryController _library;
|
||||
final Strings _strings;
|
||||
|
||||
/// Whether [url] is one of the internal pages.
|
||||
bool handles(String url) => isInternal(url);
|
||||
|
||||
/// Whether [url] belongs to the internal mews: scheme.
|
||||
static bool isInternal(String url) =>
|
||||
Uri.tryParse(url)?.scheme == 'mews';
|
||||
|
||||
/// The internal page for [url], or null when the name is unknown.
|
||||
Future<MewsPage?> pageFor(String url) async {
|
||||
// mews:history is an opaque URI: the page name is the path, not a host.
|
||||
switch (Uri.tryParse(url)?.path) {
|
||||
case 'history':
|
||||
return _history(url);
|
||||
case 'bookmarks':
|
||||
return _bookmarks(url);
|
||||
case 'subscriptions':
|
||||
return _subscriptions(url);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MewsPage> _history(String url) async {
|
||||
final visits = await _db.recentVisits();
|
||||
return _page(
|
||||
url: url,
|
||||
title: _strings.t('history-title'),
|
||||
empty: _strings.t('history-empty'),
|
||||
entries: [
|
||||
for (final visit in visits)
|
||||
_Entry(
|
||||
url: visit.url,
|
||||
// Dated links are the Mews posts pattern, so history reads like
|
||||
// any other entry list.
|
||||
label: '${_day(visit.visitedAt)} ${_clock(visit.visitedAt)}',
|
||||
title: visit.title ?? visit.url,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<MewsPage> _bookmarks(String url) async {
|
||||
final bookmarks = await _db.bookmarksByDate();
|
||||
return _page(
|
||||
url: url,
|
||||
title: _strings.t('bookmarks-title'),
|
||||
empty: _strings.t('bookmarks-empty'),
|
||||
entries: [
|
||||
for (final bookmark in bookmarks)
|
||||
_Entry(
|
||||
url: bookmark.url,
|
||||
label: _day(bookmark.createdAt),
|
||||
title: bookmark.title ?? bookmark.url,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<MewsPage> _subscriptions(String url) async {
|
||||
final subscriptions = await _db.allSubscriptions();
|
||||
return _page(
|
||||
url: url,
|
||||
title: _strings.t('subscriptions-title'),
|
||||
empty: _strings.t('subscriptions-empty'),
|
||||
entries: [
|
||||
for (final subscription in subscriptions)
|
||||
_Entry(
|
||||
url: subscription.url,
|
||||
label: _day(subscription.createdAt),
|
||||
title: (subscription.title ?? subscription.url) +
|
||||
_newPostsNote(subscription.url),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _newPostsNote(String siteUrl) {
|
||||
final count = _library.newEntries.value[siteUrl]?.length ?? 0;
|
||||
if (count == 0) return '';
|
||||
final note = count == 1
|
||||
? _strings.t('subscriptions-new-one')
|
||||
: _strings.t('subscriptions-new-many', {'count': count});
|
||||
return ' \u2014 $note';
|
||||
}
|
||||
|
||||
MewsPage _page({
|
||||
required String url,
|
||||
required String title,
|
||||
required String empty,
|
||||
required List<_Entry> entries,
|
||||
}) {
|
||||
final blocks = <MewsBlock>[
|
||||
HeadingBlock(1, [MewsText(title)]),
|
||||
if (entries.isEmpty)
|
||||
ParagraphBlock([MewsText(empty)])
|
||||
else
|
||||
ListBlock(
|
||||
ordered: false,
|
||||
start: 1,
|
||||
items: [
|
||||
for (final entry in entries)
|
||||
MewsListItem([
|
||||
ParagraphBlock([
|
||||
MewsLink(entry.url, [MewsText('${entry.label} ${entry.title}')]),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
return MewsPage(
|
||||
url: url,
|
||||
// Internal pages carry a marker so they render as Mews pages, not as
|
||||
// plainly-shown external ones.
|
||||
specVersion: 'internal',
|
||||
title: title,
|
||||
body: blocks,
|
||||
lang: 'en',
|
||||
direction: TextDirection.ltr,
|
||||
variant: MewsVariant.plain,
|
||||
entries: const [],
|
||||
);
|
||||
}
|
||||
|
||||
String _day(DateTime date) =>
|
||||
'${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}';
|
||||
|
||||
String _clock(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}';
|
||||
|
||||
String _two(int value) => value.toString().padLeft(2, '0');
|
||||
}
|
||||
|
||||
class _Entry {
|
||||
const _Entry({required this.url, required this.label, required this.title});
|
||||
|
||||
final String url;
|
||||
final String label;
|
||||
final String title;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue