feat: add a hub for the client's own pages and gate mews:do action links
This commit is contained in:
parent
df705d831f
commit
b311b9f7dd
21 changed files with 966 additions and 404 deletions
5
SPEC.md
5
SPEC.md
|
|
@ -61,7 +61,8 @@ Use the `actors` package only where Dart Isolate isolation is genuinely useful:
|
||||||
|
|
||||||
The v1 client presents one reader screen:
|
The v1 client presents one reader screen:
|
||||||
|
|
||||||
- An address field pinned at the top; it hides when the reader scrolls down and returns when they scroll up or reach the top. Its leading icons open the settings page and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed.
|
- An address field pinned at the top; it hides when the reader scrolls down and returns when they scroll up or reach the top. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed.
|
||||||
- A swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well.
|
- A swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well.
|
||||||
- Settings, history, bookmarks and subscriptions are internal pages under the `mews:` scheme. Each is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions. A setting is a link: following `mews:setting/<group>/<value>` applies the value and rerenders the page in place without adding a history entry.
|
- The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription.
|
||||||
|
- A change is a link too: following `mews:do/<verb>` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network.
|
||||||
- Fonts are bundled, and v1 ships Atkinson Hyperlegible. Adding a font is one TTF in `app/assets/fonts/`, one `fonts` entry in `app/pubspec.yaml`, one option on the settings page, and one label in `app/locales/en-US/main.ftl`.
|
- Fonts are bundled, and v1 ships Atkinson Hyperlegible. Adding a font is one TTF in `app/assets/fonts/`, one `fonts` entry in `app/pubspec.yaml`, one option on the settings page, and one label in `app/locales/en-US/main.ftl`.
|
||||||
|
|
|
||||||
|
|
@ -151,15 +151,21 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
|
||||||
|
|
||||||
Future<FetchResult> _submitForm(String url, String method, Map<String, String> fields) async {
|
Future<FetchResult> _submitForm(String url, String method, Map<String, String> fields) async {
|
||||||
final headers = {'User-Agent': _userAgent};
|
final headers = {'User-Agent': _userAgent};
|
||||||
http.Response response;
|
final http.Response response;
|
||||||
if (method == 'post') {
|
try {
|
||||||
response = await _client
|
if (method == 'post') {
|
||||||
.post(Uri.parse(url), headers: headers, body: fields)
|
response = await _client
|
||||||
.timeout(const Duration(seconds: 20));
|
.post(Uri.parse(url), headers: headers, body: fields)
|
||||||
} else {
|
.timeout(const Duration(seconds: 20));
|
||||||
response = await _client
|
} else {
|
||||||
.get(Uri.parse(url).replace(queryParameters: fields), headers: headers)
|
response = await _client
|
||||||
.timeout(const Duration(seconds: 20));
|
.get(Uri.parse(url).replace(queryParameters: fields), headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// A refused connection, a timeout or an address the client cannot speak
|
||||||
|
// are all one failed submit to the reader.
|
||||||
|
return const FetchNetworkError();
|
||||||
}
|
}
|
||||||
if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode);
|
if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode);
|
||||||
return _parse(response.body, url);
|
return _parse(response.body, url);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import 'package:signals/signals.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
import '../actors/fetch_actor.dart';
|
import '../actors/fetch_actor.dart';
|
||||||
|
import '../internal_actions.dart';
|
||||||
import '../internal_pages.dart';
|
import '../internal_pages.dart';
|
||||||
|
import '../pages/mews_urls.dart';
|
||||||
|
|
||||||
/// What went wrong while browsing. The presentation layer maps these to
|
/// What went wrong while browsing. The presentation layer maps these to
|
||||||
/// user-facing messages; no raw codes cross into the UI.
|
/// user-facing messages; no raw codes cross into the UI.
|
||||||
|
|
@ -32,13 +34,16 @@ class BrowserController {
|
||||||
required MewsDatabase db,
|
required MewsDatabase db,
|
||||||
required Actor<FetchCommand, FetchResult> fetch,
|
required Actor<FetchCommand, FetchResult> fetch,
|
||||||
InternalPages? internalPages,
|
InternalPages? internalPages,
|
||||||
|
InternalActions? internalActions,
|
||||||
}) : _db = db,
|
}) : _db = db,
|
||||||
_fetch = fetch,
|
_fetch = fetch,
|
||||||
_internalPages = internalPages;
|
_internalPages = internalPages,
|
||||||
|
_internalActions = internalActions;
|
||||||
|
|
||||||
final MewsDatabase _db;
|
final MewsDatabase _db;
|
||||||
final Actor<FetchCommand, FetchResult> _fetch;
|
final Actor<FetchCommand, FetchResult> _fetch;
|
||||||
final InternalPages? _internalPages;
|
final InternalPages? _internalPages;
|
||||||
|
final InternalActions? _internalActions;
|
||||||
|
|
||||||
/// The stack of visited pages; the reader is at [_stackIndex].
|
/// The stack of visited pages; the reader is at [_stackIndex].
|
||||||
final List<String> _stack = [];
|
final List<String> _stack = [];
|
||||||
|
|
@ -82,14 +87,18 @@ class BrowserController {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> open(String input) async {
|
Future<void> open(String input) async {
|
||||||
final url = normalizeAddress(input);
|
final url = MewsUrls.canonical(normalizeAddress(input));
|
||||||
if (url.isEmpty) return;
|
if (url.isEmpty) return;
|
||||||
|
|
||||||
// Setting links change a value and stay on the current page instead of
|
// Action links change something and stay on the current page instead of
|
||||||
// joining the walk history.
|
// joining the walk history.
|
||||||
if (InternalPages.isSettingUrl(url)) {
|
if (MewsUrls.isAction(url)) {
|
||||||
await _internalPages?.applySetting(url);
|
// Only the client's own pages may change the reader's data. A fetched
|
||||||
await _loadCurrent();
|
// page linking to mews:do/... must not act on their behalf (spec 8.1).
|
||||||
|
if (!_onInternalPage) return;
|
||||||
|
if (await (_internalActions?.apply(url) ?? Future.value(false))) {
|
||||||
|
await _loadCurrent();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,10 +154,39 @@ class BrowserController {
|
||||||
|
|
||||||
Future<void> reload() => _loadCurrent();
|
Future<void> reload() => _loadCurrent();
|
||||||
|
|
||||||
|
/// Whether the reader is standing on one of the client's own pages.
|
||||||
|
bool get _onInternalPage =>
|
||||||
|
_stackIndex >= 0 && MewsUrls.isInternal(_stack[_stackIndex]);
|
||||||
|
|
||||||
|
/// Rebuilds the open page when it is one of ours, so a change made elsewhere
|
||||||
|
/// shows up without the reader navigating.
|
||||||
|
Future<void> refreshInternalPage() async {
|
||||||
|
if (!_onInternalPage) return;
|
||||||
|
await _loadCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
|
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
|
||||||
|
// A form on one of our own pages is a navigation or an action, never a
|
||||||
|
// network request; the fetch client cannot speak the mews scheme.
|
||||||
|
if (MewsUrls.isInternal(action)) {
|
||||||
|
final base = Uri.parse(action);
|
||||||
|
final verb = MewsUrls.verbOf(action);
|
||||||
|
return open(verb == null
|
||||||
|
? MewsUrls.page(base, fields)
|
||||||
|
: MewsUrls.action(verb, {...base.queryParameters, ...fields}));
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
final result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields));
|
final FetchResult result;
|
||||||
|
try {
|
||||||
|
result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields));
|
||||||
|
} catch (_) {
|
||||||
|
// A submit that throws out of the actor is still just a failed load.
|
||||||
|
loading.value = false;
|
||||||
|
error.value = const LoadFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case FetchFormOk(:final page):
|
case FetchFormOk(:final page):
|
||||||
|
|
@ -162,10 +200,10 @@ class BrowserController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reopens the most recent page from the previous session, if any.
|
/// Reopens the most recent page from the previous session, or the hub when
|
||||||
|
/// there is nothing to return to.
|
||||||
Future<void> restoreLastSession() async {
|
Future<void> restoreLastSession() async {
|
||||||
final url = await _db.lastVisitedUrl();
|
final url = await _db.lastVisitedUrl() ?? MewsUrls.hub;
|
||||||
if (url == null) return;
|
|
||||||
_stack.add(url);
|
_stack.add(url);
|
||||||
_stackIndex = 0;
|
_stackIndex = 0;
|
||||||
_updateNeighbors();
|
_updateNeighbors();
|
||||||
|
|
@ -216,7 +254,8 @@ class BrowserController {
|
||||||
if (page != null) {
|
if (page != null) {
|
||||||
currentPage.value = page;
|
currentPage.value = page;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
visitedUrls.value = {...visitedUrls.value, url};
|
// Our own addresses stay out of visitedUrls: a sort or filter link
|
||||||
|
// would otherwise take the visited color after one use.
|
||||||
} else {
|
} else {
|
||||||
currentPage.value = null;
|
currentPage.value = null;
|
||||||
error.value = const LoadFailed();
|
error.value = const LoadFailed();
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,16 @@ class ReaderSettingsController {
|
||||||
static const maxScale = 2.0;
|
static const maxScale = 2.0;
|
||||||
static const scaleStep = 0.125;
|
static const scaleStep = 0.125;
|
||||||
|
|
||||||
|
/// The accepted values, in the order the settings page offers them. A
|
||||||
|
/// setting arrives as a link the reader can also type, so a value outside
|
||||||
|
/// these lists is refused rather than stored.
|
||||||
|
static const themeModes = ['light', 'dark', 'system'];
|
||||||
|
static const variants = ['site', 'warm', 'cool', 'green', 'mono'];
|
||||||
|
|
||||||
final font = signal('Atkinson Hyperlegible');
|
final font = signal('Atkinson Hyperlegible');
|
||||||
final fontScale = signal(1.0);
|
final fontScale = signal(1.0);
|
||||||
final themeMode = signal('system'); // system | light | dark
|
final themeMode = signal('system');
|
||||||
final variant = signal('site'); // site | warm | cool | green | mono
|
final variant = signal('site');
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
final settings = await _db.readSettings();
|
final settings = await _db.readSettings();
|
||||||
|
|
@ -30,28 +36,37 @@ class ReaderSettingsController {
|
||||||
variant.value = settings.variant;
|
variant.value = settings.variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setFont(String family) async {
|
Future<bool> setFont(String family) async {
|
||||||
|
if (family.isEmpty || family == font.value) return false;
|
||||||
font.value = family;
|
font.value = family;
|
||||||
await _persist();
|
await _persist();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> decreaseSize() => _setScale(fontScale.value - scaleStep);
|
Future<bool> decreaseSize() => _setScale(fontScale.value - scaleStep);
|
||||||
Future<void> increaseSize() => _setScale(fontScale.value + scaleStep);
|
Future<bool> increaseSize() => _setScale(fontScale.value + scaleStep);
|
||||||
|
|
||||||
Future<void> _setScale(double value) async {
|
Future<bool> _setScale(double value) async {
|
||||||
if (value < minScale || value > maxScale) return;
|
if (value < minScale || value > maxScale) return false;
|
||||||
fontScale.value = value;
|
fontScale.value = value;
|
||||||
await _persist();
|
await _persist();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setThemeMode(String mode) async {
|
Future<bool> setThemeMode(String mode) async {
|
||||||
|
if (!themeModes.contains(mode) || mode == themeMode.value) return false;
|
||||||
themeMode.value = mode;
|
themeMode.value = mode;
|
||||||
await _persist();
|
await _persist();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setVariant(String variant) async {
|
Future<bool> setVariant(String variant) async {
|
||||||
|
if (!variants.contains(variant) || variant == this.variant.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
this.variant.value = variant;
|
this.variant.value = variant;
|
||||||
await _persist();
|
await _persist();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persist() => _db.saveSettings(
|
Future<void> _persist() => _db.saveSettings(
|
||||||
|
|
|
||||||
36
app/lib/application/internal_actions.dart
Normal file
36
app/lib/application/internal_actions.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'controllers/reader_settings_controller.dart'
|
||||||
|
show ReaderSettingsController;
|
||||||
|
import 'pages/mews_urls.dart';
|
||||||
|
|
||||||
|
/// Applies the change an action address encodes, e.g. mews:do/set-theme.
|
||||||
|
/// Returns whether anything changed, so the navigator can skip a rerender that
|
||||||
|
/// would show the same page.
|
||||||
|
///
|
||||||
|
/// An unknown verb or a missing argument does nothing and reports nothing: the
|
||||||
|
/// only links carrying these addresses are the ones the client builds, so a
|
||||||
|
/// miss is a bug rather than something to explain to the reader.
|
||||||
|
class InternalActions {
|
||||||
|
InternalActions({required ReaderSettingsController settings})
|
||||||
|
: _settings = settings;
|
||||||
|
|
||||||
|
final ReaderSettingsController _settings;
|
||||||
|
|
||||||
|
Future<bool> apply(String url) async {
|
||||||
|
final verb = MewsUrls.verbOf(url);
|
||||||
|
if (verb == null) return false;
|
||||||
|
final args = Uri.parse(url).queryParameters;
|
||||||
|
return switch (verb) {
|
||||||
|
'set-font' => _settings.setFont(args['value'] ?? ''),
|
||||||
|
'set-size' => _setSize(args['step']),
|
||||||
|
'set-theme' => _settings.setThemeMode(args['value'] ?? ''),
|
||||||
|
'set-colors' => _settings.setVariant(args['value'] ?? ''),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _setSize(String? step) => switch (step) {
|
||||||
|
'up' => _settings.increaseSize(),
|
||||||
|
'down' => _settings.decreaseSize(),
|
||||||
|
_ => Future.value(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import '../../database/database.dart';
|
import '../database/database.dart';
|
||||||
import '../../l10n/strings.dart';
|
import '../l10n/strings.dart';
|
||||||
import '../../mews/document.dart';
|
import '../mews/document.dart';
|
||||||
import 'controllers/reader_settings_controller.dart'
|
import 'controllers/reader_settings_controller.dart'
|
||||||
show LibraryController, ReaderSettingsController;
|
show LibraryController, ReaderSettingsController;
|
||||||
|
import 'pages/bookmarks_page.dart';
|
||||||
|
import 'pages/history_page.dart';
|
||||||
|
import 'pages/hub_page.dart';
|
||||||
|
import 'pages/mews_urls.dart';
|
||||||
|
import 'pages/settings_page.dart';
|
||||||
|
import 'pages/subscriptions_page.dart';
|
||||||
|
|
||||||
/// Builds the internal History, Bookmarks and Subscriptions pages as real
|
/// Builds the client's own pages as real [MewsPage]s: they render like any
|
||||||
/// [MewsPage]s: they render like any other page and take part in the walk
|
/// other page and take part in the walk history, so swiping and back/forward
|
||||||
/// history, so swiping and back/forward treat them like visited pages.
|
/// treat them like visited pages.
|
||||||
class InternalPages {
|
class InternalPages {
|
||||||
InternalPages({
|
InternalPages({
|
||||||
required MewsDatabase db,
|
required MewsDatabase db,
|
||||||
|
|
@ -18,296 +24,27 @@ class InternalPages {
|
||||||
_settings = settings,
|
_settings = settings,
|
||||||
_strings = strings;
|
_strings = strings;
|
||||||
|
|
||||||
static const historyUrl = 'mews:history';
|
|
||||||
static const bookmarksUrl = 'mews:bookmarks';
|
|
||||||
static const subscriptionsUrl = 'mews:subscriptions';
|
|
||||||
static const settingsUrl = 'mews:settings';
|
|
||||||
|
|
||||||
final MewsDatabase _db;
|
final MewsDatabase _db;
|
||||||
final LibraryController _library;
|
final LibraryController _library;
|
||||||
final ReaderSettingsController _settings;
|
final ReaderSettingsController _settings;
|
||||||
final Strings _strings;
|
final Strings _strings;
|
||||||
|
|
||||||
/// Whether [url] is one of the internal pages.
|
/// Whether [url] is one of the internal pages.
|
||||||
bool handles(String url) => isInternal(url);
|
bool handles(String url) => MewsUrls.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.
|
/// The internal page for [url], or null when the name is unknown.
|
||||||
Future<MewsPage?> pageFor(String url) async {
|
Future<MewsPage?> pageFor(String url) {
|
||||||
// mews:history is an opaque URI: the page name is the path, not a host.
|
// Internal addresses are opaque, so the page name is the path, not a host.
|
||||||
switch (Uri.tryParse(url)?.path) {
|
final uri = Uri.parse(MewsUrls.canonical(url));
|
||||||
case 'history':
|
return switch (uri.path) {
|
||||||
return _history(url);
|
'hub' => buildHubPage(uri, db: _db, strings: _strings),
|
||||||
case 'bookmarks':
|
'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings),
|
||||||
return _bookmarks(url);
|
'subscriptions' => buildSubscriptionsPage(uri,
|
||||||
case 'subscriptions':
|
db: _db, library: _library, strings: _strings),
|
||||||
return _subscriptions(url);
|
'history' => buildHistoryPage(uri, db: _db, strings: _strings),
|
||||||
case 'settings':
|
'settings' =>
|
||||||
return _settingsPage(url);
|
buildSettingsPage(uri, settings: _settings, strings: _strings),
|
||||||
default:
|
_ => Future.value(null),
|
||||||
return null;
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether [url] changes a setting instead of visiting a page, e.g.
|
|
||||||
/// mews:setting/theme/light.
|
|
||||||
static bool isSettingUrl(String url) =>
|
|
||||||
Uri.tryParse(url)?.path.startsWith('setting/') ?? false;
|
|
||||||
|
|
||||||
/// Applies the setting encoded in [url]; the page stays where it is and
|
|
||||||
/// rerenders with the new value.
|
|
||||||
Future<void> applySetting(String url) async {
|
|
||||||
final segments =
|
|
||||||
Uri.tryParse(url)?.path.split('/') ?? const <String>[];
|
|
||||||
if (segments.length < 3) return;
|
|
||||||
final value = Uri.decodeComponent(segments.last);
|
|
||||||
switch (segments[1]) {
|
|
||||||
case 'font':
|
|
||||||
await _settings.setFont(value);
|
|
||||||
case 'size':
|
|
||||||
if (value == 'decrease') {
|
|
||||||
await _settings.decreaseSize();
|
|
||||||
} else if (value == 'increase') {
|
|
||||||
await _settings.increaseSize();
|
|
||||||
}
|
|
||||||
case 'theme':
|
|
||||||
await _settings.setThemeMode(value);
|
|
||||||
case 'colors':
|
|
||||||
await _settings.setVariant(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MewsPage _settingsPage(String url) {
|
|
||||||
return _page(
|
|
||||||
url: url,
|
|
||||||
title: _strings.t('settings-page-title'),
|
|
||||||
empty: '',
|
|
||||||
entries: const [],
|
|
||||||
extraBlocks: [
|
|
||||||
// Typeface: the current choice is plain text; alternatives are links.
|
|
||||||
HeadingBlock(2, [MewsText(_strings.t('settings-font-heading'))]),
|
|
||||||
..._options('font', [
|
|
||||||
_Option(_strings.t('settings-font-atkinson'), 'Atkinson Hyperlegible'),
|
|
||||||
], current: _settings.font.value),
|
|
||||||
// Text size steps between the configured bounds; the current value
|
|
||||||
// stays plain text between the two actions.
|
|
||||||
HeadingBlock(2, [MewsText(_strings.t('settings-size-heading'))]),
|
|
||||||
ListBlock(
|
|
||||||
ordered: false,
|
|
||||||
start: 1,
|
|
||||||
items: [
|
|
||||||
if (_settings.fontScale.value > ReaderSettingsController.minScale)
|
|
||||||
MewsListItem([
|
|
||||||
ParagraphBlock([
|
|
||||||
_link('mews:setting/size/decrease',
|
|
||||||
_strings.t('settings-size-decrease')),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
MewsListItem([
|
|
||||||
ParagraphBlock([
|
|
||||||
MewsEmphasis(EmphasisStyle.strong, [
|
|
||||||
MewsText('${(_settings.fontScale.value * 100).round()}%'),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
if (_settings.fontScale.value < ReaderSettingsController.maxScale)
|
|
||||||
MewsListItem([
|
|
||||||
ParagraphBlock([
|
|
||||||
_link('mews:setting/size/increase',
|
|
||||||
_strings.t('settings-size-increase')),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
HeadingBlock(2, [MewsText(_strings.t('settings-theme-heading'))]),
|
|
||||||
..._options('theme', [
|
|
||||||
_Option(_strings.t('settings-theme-light'), 'light'),
|
|
||||||
_Option(_strings.t('settings-theme-dark'), 'dark'),
|
|
||||||
_Option(_strings.t('settings-theme-system'), 'system'),
|
|
||||||
], current: _settings.themeMode.value),
|
|
||||||
HeadingBlock(2, [MewsText(_strings.t('settings-variant-heading'))]),
|
|
||||||
..._options('colors', [
|
|
||||||
_Option(_strings.t('settings-variant-site'), 'site'),
|
|
||||||
_Option(_strings.t('settings-variant-warm'), 'warm'),
|
|
||||||
_Option(_strings.t('settings-variant-cool'), 'cool'),
|
|
||||||
_Option(_strings.t('settings-variant-green'), 'green'),
|
|
||||||
_Option(_strings.t('settings-variant-mono'), 'mono'),
|
|
||||||
], current: _settings.variant.value),
|
|
||||||
HeadingBlock(2, [MewsText(_strings.t('settings-library-heading'))]),
|
|
||||||
ListBlock(
|
|
||||||
ordered: false,
|
|
||||||
start: 1,
|
|
||||||
items: [
|
|
||||||
for (final entry in [
|
|
||||||
(historyUrl, _strings.t('settings-history')),
|
|
||||||
(bookmarksUrl, _strings.t('settings-bookmarks')),
|
|
||||||
(subscriptionsUrl, _strings.t('settings-subscriptions')),
|
|
||||||
])
|
|
||||||
MewsListItem([
|
|
||||||
ParagraphBlock([_link(entry.$1, entry.$2)]),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One item per option: the current value in bold, the others as links.
|
|
||||||
List<MewsBlock> _options(String group, List<_Option> options,
|
|
||||||
{required String current}) {
|
|
||||||
return [
|
|
||||||
ListBlock(
|
|
||||||
ordered: false,
|
|
||||||
start: 1,
|
|
||||||
items: [
|
|
||||||
for (final option in options)
|
|
||||||
MewsListItem([
|
|
||||||
if (option.value == current)
|
|
||||||
ParagraphBlock([
|
|
||||||
MewsEmphasis(
|
|
||||||
EmphasisStyle.strong, [MewsText(option.label)]),
|
|
||||||
])
|
|
||||||
else
|
|
||||||
ParagraphBlock([
|
|
||||||
_link('mews:setting/$group/${option.value}', option.label),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
MewsLink _link(String href, String label) =>
|
|
||||||
MewsLink(href, [MewsText(label)]);
|
|
||||||
|
|
||||||
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,
|
|
||||||
List<MewsBlock> extraBlocks = const [],
|
|
||||||
}) {
|
|
||||||
final blocks = <MewsBlock>[
|
|
||||||
HeadingBlock(1, [MewsText(title)]),
|
|
||||||
if (entries.isEmpty && extraBlocks.isEmpty)
|
|
||||||
ParagraphBlock([MewsText(empty)])
|
|
||||||
else if (entries.isNotEmpty)
|
|
||||||
ListBlock(
|
|
||||||
ordered: false,
|
|
||||||
start: 1,
|
|
||||||
items: [
|
|
||||||
for (final entry in entries)
|
|
||||||
MewsListItem([
|
|
||||||
ParagraphBlock([
|
|
||||||
MewsLink(entry.url, [MewsText('${entry.label} ${entry.title}')]),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
blocks.addAll(extraBlocks);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One selectable value in a settings option group.
|
|
||||||
class _Option {
|
|
||||||
const _Option(this.label, this.value);
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
app/lib/application/pages/bookmarks_page.dart
Normal file
32
app/lib/application/pages/bookmarks_page.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import '../../database/database.dart';
|
||||||
|
import '../../l10n/strings.dart';
|
||||||
|
import '../../mews/document.dart';
|
||||||
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
|
Future<MewsPage> buildBookmarksPage(
|
||||||
|
Uri url, {
|
||||||
|
required MewsDatabase db,
|
||||||
|
required Strings strings,
|
||||||
|
}) async {
|
||||||
|
final bookmarks = await db.bookmarksByDate();
|
||||||
|
final title = strings.t('bookmarks-title');
|
||||||
|
|
||||||
|
return internalPage(
|
||||||
|
url: url,
|
||||||
|
title: title,
|
||||||
|
body: [
|
||||||
|
...chrome(title: title, current: url, strings: strings),
|
||||||
|
if (bookmarks.isEmpty)
|
||||||
|
ParagraphBlock([MewsText(strings.t('bookmarks-empty'))])
|
||||||
|
else
|
||||||
|
itemList([
|
||||||
|
for (final bookmark in bookmarks)
|
||||||
|
itemRow(
|
||||||
|
href: bookmark.url,
|
||||||
|
label: bookmark.title ?? bookmark.url,
|
||||||
|
date: dayText(bookmark.createdAt),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/lib/application/pages/history_page.dart
Normal file
32
app/lib/application/pages/history_page.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import '../../database/database.dart';
|
||||||
|
import '../../l10n/strings.dart';
|
||||||
|
import '../../mews/document.dart';
|
||||||
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
|
Future<MewsPage> buildHistoryPage(
|
||||||
|
Uri url, {
|
||||||
|
required MewsDatabase db,
|
||||||
|
required Strings strings,
|
||||||
|
}) async {
|
||||||
|
final visits = await db.recentVisits();
|
||||||
|
final title = strings.t('history-title');
|
||||||
|
|
||||||
|
return internalPage(
|
||||||
|
url: url,
|
||||||
|
title: title,
|
||||||
|
body: [
|
||||||
|
...chrome(title: title, current: url, strings: strings),
|
||||||
|
if (visits.isEmpty)
|
||||||
|
ParagraphBlock([MewsText(strings.t('history-empty'))])
|
||||||
|
else
|
||||||
|
itemList([
|
||||||
|
for (final visit in visits)
|
||||||
|
itemRow(
|
||||||
|
href: visit.url,
|
||||||
|
label: visit.title ?? visit.url,
|
||||||
|
date: dayTimeText(visit.visitedAt),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
53
app/lib/application/pages/hub_page.dart
Normal file
53
app/lib/application/pages/hub_page.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import '../../database/database.dart';
|
||||||
|
import '../../l10n/strings.dart';
|
||||||
|
import '../../mews/document.dart';
|
||||||
|
import 'mews_urls.dart';
|
||||||
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
|
/// The hub names every page the client provides and says what each one holds,
|
||||||
|
/// so the reader has somewhere to start. Its menu is the navigation, so it
|
||||||
|
/// carries no strip of its own.
|
||||||
|
Future<MewsPage> buildHubPage(
|
||||||
|
Uri url, {
|
||||||
|
required MewsDatabase db,
|
||||||
|
required Strings strings,
|
||||||
|
}) async {
|
||||||
|
final bookmarks = await db.countBookmarks();
|
||||||
|
final sites = await db.countSubscriptions();
|
||||||
|
final visits = await db.countHistory();
|
||||||
|
|
||||||
|
return internalPage(
|
||||||
|
url: url,
|
||||||
|
title: strings.t('hub-title'),
|
||||||
|
body: [
|
||||||
|
HeadingBlock(1, [MewsText(strings.t('hub-title'))]),
|
||||||
|
itemList([
|
||||||
|
itemRow(
|
||||||
|
href: MewsUrls.bookmarks,
|
||||||
|
label: strings.t('nav-bookmarks'),
|
||||||
|
note: countText(strings, bookmarks,
|
||||||
|
none: 'count-bookmarks-none',
|
||||||
|
one: 'count-bookmarks-one',
|
||||||
|
many: 'count-bookmarks-many'),
|
||||||
|
),
|
||||||
|
itemRow(
|
||||||
|
href: MewsUrls.subscriptions,
|
||||||
|
label: strings.t('nav-subscriptions'),
|
||||||
|
note: countText(strings, sites,
|
||||||
|
none: 'count-sites-none',
|
||||||
|
one: 'count-sites-one',
|
||||||
|
many: 'count-sites-many'),
|
||||||
|
),
|
||||||
|
itemRow(
|
||||||
|
href: MewsUrls.history,
|
||||||
|
label: strings.t('nav-history'),
|
||||||
|
note: countText(strings, visits,
|
||||||
|
none: 'count-pages-none',
|
||||||
|
one: 'count-pages-one',
|
||||||
|
many: 'count-pages-many'),
|
||||||
|
),
|
||||||
|
itemRow(href: MewsUrls.settings, label: strings.t('nav-settings')),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
74
app/lib/application/pages/mews_urls.dart
Normal file
74
app/lib/application/pages/mews_urls.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/// The addresses of the client's own pages, under the `mews:` scheme.
|
||||||
|
/// One spelling of the scheme, shared by the navigator and the page builders.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Internal addresses come in two shapes. A page address is a navigation and
|
||||||
|
/// joins the walk history; an action address under `do/` changes something and
|
||||||
|
/// leaves the reader where they are.
|
||||||
|
///
|
||||||
|
/// View state (sort, search, tag filter) rides in the query string rather than
|
||||||
|
/// in a signal, because swipe previews are built from the address alone and
|
||||||
|
/// going back should undo a sort.
|
||||||
|
abstract final class MewsUrls {
|
||||||
|
static const scheme = 'mews';
|
||||||
|
|
||||||
|
static const hub = 'mews:hub';
|
||||||
|
static const bookmarks = 'mews:bookmarks';
|
||||||
|
static const subscriptions = 'mews:subscriptions';
|
||||||
|
static const history = 'mews:history';
|
||||||
|
static const settings = 'mews:settings';
|
||||||
|
|
||||||
|
/// Whether [url] belongs to the internal scheme.
|
||||||
|
static bool isInternal(String url) => Uri.tryParse(url)?.scheme == scheme;
|
||||||
|
|
||||||
|
/// Whether [url] changes something instead of going somewhere, e.g.
|
||||||
|
/// mews:do/set-theme?value=dark.
|
||||||
|
static bool isAction(String url) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || uri.scheme != scheme) return false;
|
||||||
|
final segments = uri.pathSegments;
|
||||||
|
return segments.length == 2 && segments.first == 'do';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verb of an action address, or null when [url] is not one.
|
||||||
|
static String? verbOf(String url) =>
|
||||||
|
isAction(url) ? Uri.parse(url).pathSegments[1] : null;
|
||||||
|
|
||||||
|
/// A single spelling for every internal address, so the walk history entry
|
||||||
|
/// and the built page's url always compare equal. Bare `mews:` is the hub.
|
||||||
|
static String canonical(String url) {
|
||||||
|
if (!isInternal(url)) return url;
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (uri.path.isEmpty) return hub;
|
||||||
|
return uri.removeFragment().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action address carrying [args] as its query string.
|
||||||
|
static String action(String verb, [Map<String, String> args = const {}]) =>
|
||||||
|
Uri(
|
||||||
|
scheme: scheme,
|
||||||
|
path: 'do/$verb',
|
||||||
|
queryParameters: args.isEmpty ? null : args,
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
/// [base] with [changes] merged over its query string. An empty or null
|
||||||
|
/// value drops the parameter, so clearing a search box returns the whole
|
||||||
|
/// list. Merging rather than replacing keeps the other parameters, which
|
||||||
|
/// `Uri.replace(queryParameters:)` would discard.
|
||||||
|
static String page(Uri base, [Map<String, String?> changes = const {}]) {
|
||||||
|
final params = {...base.queryParameters};
|
||||||
|
for (final change in changes.entries) {
|
||||||
|
final value = change.value;
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
params.remove(change.key);
|
||||||
|
} else {
|
||||||
|
params[change.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uri(
|
||||||
|
scheme: scheme,
|
||||||
|
path: base.path,
|
||||||
|
queryParameters: params.isEmpty ? null : params,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/lib/application/pages/page_blocks.dart
Normal file
144
app/lib/application/pages/page_blocks.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/// The shared vocabulary every internal page is built from. Internal pages are
|
||||||
|
/// ordinary Mews documents, so a page has only headings, lists, paragraphs and
|
||||||
|
/// links to work with; these builders keep the result consistent.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import '../../l10n/strings.dart';
|
||||||
|
import '../../mews/document.dart';
|
||||||
|
import 'mews_urls.dart';
|
||||||
|
|
||||||
|
/// Separates independent labels on one line. A separator is layout, so it is a
|
||||||
|
/// constant here rather than a message.
|
||||||
|
const actionSeparator = ' ';
|
||||||
|
|
||||||
|
/// One choice in a group of mutually exclusive values.
|
||||||
|
typedef Choice = ({String href, String label, bool current});
|
||||||
|
|
||||||
|
/// One entry in the strip of internal pages.
|
||||||
|
typedef NavItem = ({String url, String labelId});
|
||||||
|
|
||||||
|
/// The strip carried by every internal page, in a fixed order so its position
|
||||||
|
/// never shifts under the reader.
|
||||||
|
const navItems = <NavItem>[
|
||||||
|
(url: MewsUrls.hub, labelId: 'nav-hub'),
|
||||||
|
(url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'),
|
||||||
|
(url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'),
|
||||||
|
(url: MewsUrls.history, labelId: 'nav-history'),
|
||||||
|
(url: MewsUrls.settings, labelId: 'nav-settings'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The title and the navigation strip that open every internal page. The page
|
||||||
|
/// the reader is on is plain text, matching how a current value is marked.
|
||||||
|
List<MewsBlock> chrome({
|
||||||
|
required String title,
|
||||||
|
required Uri current,
|
||||||
|
required Strings strings,
|
||||||
|
}) {
|
||||||
|
final inline = <MewsInline>[];
|
||||||
|
for (final item in navItems) {
|
||||||
|
if (inline.isNotEmpty) inline.add(const MewsText(actionSeparator));
|
||||||
|
final label = strings.t(item.labelId);
|
||||||
|
// Compare paths so a filtered or sorted page still marks its own entry.
|
||||||
|
if (Uri.parse(item.url).path == current.path) {
|
||||||
|
inline.add(MewsEmphasis(EmphasisStyle.strong, [MewsText(label)]));
|
||||||
|
} else {
|
||||||
|
inline.add(MewsLink(item.url, [MewsText(label)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
HeadingBlock(1, [MewsText(title)]),
|
||||||
|
ParagraphBlock(inline),
|
||||||
|
const RuleBlock(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row in a list: the item, then an optional note on its own line. A date
|
||||||
|
/// goes inside the link, the way a Mews page writes its own dated links (spec
|
||||||
|
/// 6.1), so the whole row is one target. One paragraph per row keeps rows
|
||||||
|
/// close together, since each paragraph carries its own bottom padding.
|
||||||
|
MewsListItem itemRow({
|
||||||
|
required String href,
|
||||||
|
required String label,
|
||||||
|
String? date,
|
||||||
|
String? note,
|
||||||
|
}) {
|
||||||
|
return MewsListItem([
|
||||||
|
ParagraphBlock([
|
||||||
|
MewsLink(href, [MewsText(date == null ? label : '$date $label')]),
|
||||||
|
if (note != null) ...[
|
||||||
|
const MewsLineBreak(),
|
||||||
|
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of mutually exclusive values: the current one plain, the rest links.
|
||||||
|
ListBlock choiceRow(List<Choice> options) {
|
||||||
|
return ListBlock(
|
||||||
|
ordered: false,
|
||||||
|
start: 1,
|
||||||
|
items: [
|
||||||
|
for (final option in options)
|
||||||
|
MewsListItem([
|
||||||
|
ParagraphBlock([
|
||||||
|
if (option.current)
|
||||||
|
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
|
||||||
|
else
|
||||||
|
MewsLink(option.href, [MewsText(option.label)]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain list of links, used for menus and library lists alike.
|
||||||
|
ListBlock itemList(List<MewsListItem> items) =>
|
||||||
|
ListBlock(ordered: false, start: 1, items: items);
|
||||||
|
|
||||||
|
/// Wraps blocks as an internal document. The marker makes the renderer treat
|
||||||
|
/// it as a Mews page rather than prepending the "not a Mews page" notice, and
|
||||||
|
/// no entries are offered so an internal page is never subscribable.
|
||||||
|
MewsPage internalPage({
|
||||||
|
required Uri url,
|
||||||
|
required String title,
|
||||||
|
required List<MewsBlock> body,
|
||||||
|
}) {
|
||||||
|
return MewsPage(
|
||||||
|
url: url.toString(),
|
||||||
|
specVersion: 'internal',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
lang: 'en',
|
||||||
|
direction: TextDirection.ltr,
|
||||||
|
variant: MewsVariant.plain,
|
||||||
|
entries: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the message for [count]. Plurals are separate messages, so the
|
||||||
|
/// choice happens here and Fluent formats the number.
|
||||||
|
String countText(
|
||||||
|
Strings strings,
|
||||||
|
int count, {
|
||||||
|
required String none,
|
||||||
|
required String one,
|
||||||
|
required String many,
|
||||||
|
}) {
|
||||||
|
return switch (count) {
|
||||||
|
0 => strings.t(none),
|
||||||
|
1 => strings.t(one),
|
||||||
|
_ => strings.t(many, {'count': count}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// YYYY-MM-DD, the shape a Mews entry link uses (spec 6.1).
|
||||||
|
String dayText(DateTime date) =>
|
||||||
|
'${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}';
|
||||||
|
|
||||||
|
String clockText(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}';
|
||||||
|
|
||||||
|
/// The day and the time of day, for history rows.
|
||||||
|
String dayTimeText(DateTime date) => '${dayText(date)} ${clockText(date)}';
|
||||||
|
|
||||||
|
String _two(int value) => value.toString().padLeft(2, '0');
|
||||||
100
app/lib/application/pages/settings_page.dart
Normal file
100
app/lib/application/pages/settings_page.dart
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import '../../l10n/strings.dart';
|
||||||
|
import '../../mews/document.dart';
|
||||||
|
import '../controllers/reader_settings_controller.dart'
|
||||||
|
show ReaderSettingsController;
|
||||||
|
import 'mews_urls.dart';
|
||||||
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
|
/// The bundled typefaces the reader can pick. Adding a font is one TTF in
|
||||||
|
/// assets/fonts, one entry in pubspec.yaml, one entry here and one label in
|
||||||
|
/// the locale file.
|
||||||
|
const _fonts = <({String family, String labelId})>[
|
||||||
|
(family: 'Atkinson Hyperlegible', labelId: 'settings-font-atkinson'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Message ids for the values the controller accepts. Kept literal so every
|
||||||
|
/// id in the locale file can be found by searching for it.
|
||||||
|
const _themeLabels = {
|
||||||
|
'light': 'settings-theme-light',
|
||||||
|
'dark': 'settings-theme-dark',
|
||||||
|
'system': 'settings-theme-system',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _variantLabels = {
|
||||||
|
'site': 'settings-variant-site',
|
||||||
|
'warm': 'settings-variant-warm',
|
||||||
|
'cool': 'settings-variant-cool',
|
||||||
|
'green': 'settings-variant-green',
|
||||||
|
'mono': 'settings-variant-mono',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<MewsPage> buildSettingsPage(
|
||||||
|
Uri url, {
|
||||||
|
required ReaderSettingsController settings,
|
||||||
|
required Strings strings,
|
||||||
|
}) async {
|
||||||
|
final title = strings.t('settings-page-title');
|
||||||
|
final scale = settings.fontScale.value;
|
||||||
|
|
||||||
|
return internalPage(
|
||||||
|
url: url,
|
||||||
|
title: title,
|
||||||
|
body: [
|
||||||
|
...chrome(title: title, current: url, strings: strings),
|
||||||
|
HeadingBlock(2, [MewsText(strings.t('settings-font-heading'))]),
|
||||||
|
choiceRow([
|
||||||
|
for (final font in _fonts)
|
||||||
|
(
|
||||||
|
href: MewsUrls.action('set-font', {'value': font.family}),
|
||||||
|
label: strings.t(font.labelId),
|
||||||
|
current: font.family == settings.font.value,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
// Text size steps between the controller's bounds, with the current
|
||||||
|
// value between the two steps.
|
||||||
|
HeadingBlock(2, [MewsText(strings.t('settings-size-heading'))]),
|
||||||
|
itemList([
|
||||||
|
if (scale > ReaderSettingsController.minScale)
|
||||||
|
MewsListItem([
|
||||||
|
ParagraphBlock([
|
||||||
|
MewsLink(MewsUrls.action('set-size', {'step': 'down'}),
|
||||||
|
[MewsText(strings.t('settings-size-decrease'))]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
MewsListItem([
|
||||||
|
ParagraphBlock([
|
||||||
|
MewsEmphasis(EmphasisStyle.strong, [
|
||||||
|
MewsText(strings.t('settings-size-value',
|
||||||
|
{'percent': (scale * 100).round()})),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
if (scale < ReaderSettingsController.maxScale)
|
||||||
|
MewsListItem([
|
||||||
|
ParagraphBlock([
|
||||||
|
MewsLink(MewsUrls.action('set-size', {'step': 'up'}),
|
||||||
|
[MewsText(strings.t('settings-size-increase'))]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
HeadingBlock(2, [MewsText(strings.t('settings-theme-heading'))]),
|
||||||
|
choiceRow([
|
||||||
|
for (final mode in ReaderSettingsController.themeModes)
|
||||||
|
(
|
||||||
|
href: MewsUrls.action('set-theme', {'value': mode}),
|
||||||
|
label: strings.t(_themeLabels[mode]!),
|
||||||
|
current: mode == settings.themeMode.value,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
HeadingBlock(2, [MewsText(strings.t('settings-variant-heading'))]),
|
||||||
|
choiceRow([
|
||||||
|
for (final variant in ReaderSettingsController.variants)
|
||||||
|
(
|
||||||
|
href: MewsUrls.action('set-colors', {'value': variant}),
|
||||||
|
label: strings.t(_variantLabels[variant]!),
|
||||||
|
current: variant == settings.variant.value,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
46
app/lib/application/pages/subscriptions_page.dart
Normal file
46
app/lib/application/pages/subscriptions_page.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import '../../database/database.dart';
|
||||||
|
import '../../l10n/strings.dart';
|
||||||
|
import '../../mews/document.dart';
|
||||||
|
import '../controllers/reader_settings_controller.dart' show LibraryController;
|
||||||
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
|
Future<MewsPage> buildSubscriptionsPage(
|
||||||
|
Uri url, {
|
||||||
|
required MewsDatabase db,
|
||||||
|
required LibraryController library,
|
||||||
|
required Strings strings,
|
||||||
|
}) async {
|
||||||
|
final sites = await db.allSubscriptions();
|
||||||
|
final title = strings.t('subscriptions-title');
|
||||||
|
|
||||||
|
return internalPage(
|
||||||
|
url: url,
|
||||||
|
title: title,
|
||||||
|
body: [
|
||||||
|
...chrome(title: title, current: url, strings: strings),
|
||||||
|
if (sites.isEmpty)
|
||||||
|
ParagraphBlock([MewsText(strings.t('subscriptions-empty'))])
|
||||||
|
else
|
||||||
|
itemList([
|
||||||
|
for (final site in sites)
|
||||||
|
itemRow(
|
||||||
|
href: site.url,
|
||||||
|
label: site.title ?? site.url,
|
||||||
|
date: dayText(site.createdAt),
|
||||||
|
note: _unreadNote(library, strings, site.url),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unread count for a site, or null when it has nothing new, so the row
|
||||||
|
/// carries no empty line.
|
||||||
|
String? _unreadNote(LibraryController library, Strings strings, String siteUrl) {
|
||||||
|
final count = library.newEntries.value[siteUrl]?.length ?? 0;
|
||||||
|
if (count == 0) return null;
|
||||||
|
return countText(strings, count,
|
||||||
|
none: 'count-unread-none',
|
||||||
|
one: 'count-unread-one',
|
||||||
|
many: 'count-unread-many');
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,19 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
return row?.url;
|
return row?.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row counts for the hub, so it can say what each page holds without
|
||||||
|
/// reading every row.
|
||||||
|
Future<int> countHistory() => _countOf(historyItems, historyItems.id);
|
||||||
|
Future<int> countBookmarks() => _countOf(bookmarks, bookmarks.id);
|
||||||
|
Future<int> countSubscriptions() => _countOf(subscriptions, subscriptions.id);
|
||||||
|
|
||||||
|
Future<int> _countOf(ResultSetImplementation<HasResultSet, dynamic> table,
|
||||||
|
GeneratedColumn<int> column) async {
|
||||||
|
final total = column.count();
|
||||||
|
final query = selectOnly(table)..addColumns([total]);
|
||||||
|
return (await query.getSingle()).read(total) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// The most recent external visits, newest first.
|
/// The most recent external visits, newest first.
|
||||||
Future<List<HistoryItem>> recentVisits({int limit = 50}) async {
|
Future<List<HistoryItem>> recentVisits({int limit = 50}) async {
|
||||||
final query = (select(historyItems)
|
final query = (select(historyItems)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||||
import '../application/actors/fetch_actor.dart';
|
import '../application/actors/fetch_actor.dart';
|
||||||
import '../application/controllers/browser_controller.dart';
|
import '../application/controllers/browser_controller.dart';
|
||||||
import '../application/controllers/reader_settings_controller.dart';
|
import '../application/controllers/reader_settings_controller.dart';
|
||||||
|
import '../application/internal_actions.dart';
|
||||||
import '../application/internal_pages.dart';
|
import '../application/internal_pages.dart';
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
import '../l10n/strings.dart';
|
import '../l10n/strings.dart';
|
||||||
|
|
@ -26,7 +27,13 @@ class AppDependencies {
|
||||||
library = LibraryController(db);
|
library = LibraryController(db);
|
||||||
internalPages = InternalPages(
|
internalPages = InternalPages(
|
||||||
db: db, library: library, settings: settings, strings: this.strings);
|
db: db, library: library, settings: settings, strings: this.strings);
|
||||||
browser = BrowserController(db: db, fetch: this.fetch, internalPages: internalPages);
|
internalActions = InternalActions(settings: settings);
|
||||||
|
browser = BrowserController(
|
||||||
|
db: db,
|
||||||
|
fetch: this.fetch,
|
||||||
|
internalPages: internalPages,
|
||||||
|
internalActions: internalActions,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Localized strings, also used to title the internal pages.
|
/// Localized strings, also used to title the internal pages.
|
||||||
|
|
@ -38,6 +45,7 @@ class AppDependencies {
|
||||||
late final ReaderSettingsController settings;
|
late final ReaderSettingsController settings;
|
||||||
late final LibraryController library;
|
late final LibraryController library;
|
||||||
late final InternalPages internalPages;
|
late final InternalPages internalPages;
|
||||||
|
late final InternalActions internalActions;
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
await settings.load();
|
await settings.load();
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatf
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||||
|
|
||||||
import '../../application/internal_pages.dart';
|
import '../../application/pages/mews_urls.dart';
|
||||||
import '../app.dart';
|
import '../app.dart';
|
||||||
|
|
||||||
/// The single-field address bar. The left icon opens the settings page and,
|
/// The single-field address bar. The left icon opens the hub and, on pointer
|
||||||
/// on pointer platforms, carries the back and forward buttons; the right
|
/// platforms, carries the back and forward buttons; the right icons reload,
|
||||||
/// icons reload, subscribe (only when the page offers something to subscribe
|
/// subscribe (only when the page offers something to subscribe to) and
|
||||||
/// to) and bookmark the current page.
|
/// bookmark the current page.
|
||||||
class AddressBar extends StatefulWidget {
|
class AddressBar extends StatefulWidget {
|
||||||
const AddressBar({super.key});
|
const AddressBar({super.key});
|
||||||
|
|
||||||
|
|
@ -65,10 +65,17 @@ class _AddressBarState extends State<AddressBar> {
|
||||||
// Only pages with dated entry links or an Atom feed can be subscribed
|
// Only pages with dated entry links or an Atom feed can be subscribed
|
||||||
// to (mews spec, section 6).
|
// to (mews spec, section 6).
|
||||||
final canSubscribe = page != null &&
|
final canSubscribe = page != null &&
|
||||||
!InternalPages.isInternal(page.url) &&
|
!MewsUrls.isInternal(page.url) &&
|
||||||
(page.atomFeedHref != null || page.entries.isNotEmpty);
|
(page.atomFeedHref != null || page.entries.isNotEmpty);
|
||||||
final canBookmark =
|
final canBookmark = page != null && !MewsUrls.isInternal(page.url);
|
||||||
page != null && !InternalPages.isInternal(page.url);
|
|
||||||
|
// One button, two layouts: on pointer platforms it sits beside the
|
||||||
|
// back and forward buttons.
|
||||||
|
final hubButton = IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
tooltip: strings.t('reader-open-hub'),
|
||||||
|
onPressed: () => deps.browser.open(MewsUrls.hub),
|
||||||
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||||
|
|
@ -93,12 +100,7 @@ class _AddressBarState extends State<AddressBar> {
|
||||||
? Row(
|
? Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
hubButton,
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
tooltip: strings.t('reader-open-settings'),
|
|
||||||
onPressed: () =>
|
|
||||||
deps.browser.open(InternalPages.settingsUrl),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
tooltip: strings.t('reader-back'),
|
tooltip: strings.t('reader-back'),
|
||||||
|
|
@ -115,12 +117,7 @@ class _AddressBarState extends State<AddressBar> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: IconButton(
|
: hubButton,
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
tooltip: strings.t('reader-open-settings'),
|
|
||||||
onPressed: () =>
|
|
||||||
deps.browser.open(InternalPages.settingsUrl),
|
|
||||||
),
|
|
||||||
prefixIconConstraints: hasButtons
|
prefixIconConstraints: hasButtons
|
||||||
? const BoxConstraints(minWidth: 138, minHeight: 48)
|
? const BoxConstraints(minWidth: 138, minHeight: 48)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -140,8 +137,8 @@ class _AddressBarState extends State<AddressBar> {
|
||||||
: Icons.notifications_outlined,
|
: Icons.notifications_outlined,
|
||||||
),
|
),
|
||||||
tooltip: deps.library.isSubscribed(page.url)
|
tooltip: deps.library.isSubscribed(page.url)
|
||||||
? strings.t('settings-unsubscribe')
|
? strings.t('reader-unsubscribe')
|
||||||
: strings.t('settings-subscribe'),
|
: strings.t('reader-subscribe'),
|
||||||
onPressed: () => deps.library
|
onPressed: () => deps.library
|
||||||
.toggleSubscription(page.url, page.title, page.entries),
|
.toggleSubscription(page.url, page.title, page.entries),
|
||||||
),
|
),
|
||||||
|
|
@ -153,8 +150,8 @@ class _AddressBarState extends State<AddressBar> {
|
||||||
: Icons.star_border,
|
: Icons.star_border,
|
||||||
),
|
),
|
||||||
tooltip: deps.library.isBookmarked(page.url)
|
tooltip: deps.library.isBookmarked(page.url)
|
||||||
? strings.t('settings-bookmark-remove')
|
? strings.t('reader-bookmark-remove')
|
||||||
: strings.t('settings-bookmark-add'),
|
: strings.t('reader-bookmark-add'),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
deps.library.toggleBookmark(page.url, page.title),
|
deps.library.toggleBookmark(page.url, page.title),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,32 +4,42 @@ reader-address-placeholder = Enter a web address
|
||||||
# Accessibility label for the address field.
|
# Accessibility label for the address field.
|
||||||
reader-address-label = Web address
|
reader-address-label = Web address
|
||||||
reader-reload = Reload this page
|
reader-reload = Reload this page
|
||||||
reader-open-settings = Open settings
|
reader-open-hub = Open your Mews pages
|
||||||
reader-back = Go back
|
reader-back = Go back
|
||||||
reader-forward = Go forward
|
reader-forward = Go forward
|
||||||
|
reader-bookmark-add = Add bookmark
|
||||||
|
reader-bookmark-remove = Remove bookmark
|
||||||
|
reader-subscribe = Subscribe to this site
|
||||||
|
reader-unsubscribe = Stop subscribing
|
||||||
|
|
||||||
## Settings page
|
## Navigation
|
||||||
|
|
||||||
settings-page-title = Settings
|
# The strip of the app's own pages, carried by each of them.
|
||||||
settings-font-heading = Typeface
|
nav-hub = Mews
|
||||||
settings-font-atkinson = Atkinson Hyperlegible
|
nav-bookmarks = Bookmarks
|
||||||
settings-size-heading = Text size
|
nav-subscriptions = Subscriptions
|
||||||
settings-size-decrease = Smaller text
|
nav-history = History
|
||||||
settings-size-increase = Larger text
|
nav-settings = Settings
|
||||||
settings-theme-heading = Theme
|
|
||||||
settings-theme-light = Light
|
## Hub
|
||||||
settings-theme-dark = Dark
|
|
||||||
settings-theme-system = Follow system
|
hub-title = Mews
|
||||||
settings-variant-heading = Colors
|
|
||||||
settings-variant-site = Site
|
## Counts
|
||||||
settings-variant-warm = Warm
|
|
||||||
settings-variant-cool = Cool
|
# Shown under an item. Format the number in code.
|
||||||
settings-variant-green = Green
|
count-bookmarks-none = No bookmarks yet
|
||||||
settings-variant-mono = Mono
|
count-bookmarks-one = 1 bookmark
|
||||||
settings-bookmark-add = Add bookmark
|
count-bookmarks-many = { $count } bookmarks
|
||||||
settings-bookmark-remove = Remove bookmark
|
count-sites-none = No sites yet
|
||||||
settings-subscribe = Subscribe to this site
|
count-sites-one = 1 site
|
||||||
settings-unsubscribe = Stop subscribing
|
count-sites-many = { $count } sites
|
||||||
|
count-pages-none = No pages yet
|
||||||
|
count-pages-one = 1 page
|
||||||
|
count-pages-many = { $count } pages
|
||||||
|
count-unread-none = No unread posts
|
||||||
|
count-unread-one = 1 unread post
|
||||||
|
count-unread-many = { $count } unread posts
|
||||||
|
|
||||||
## Library pages
|
## Library pages
|
||||||
|
|
||||||
|
|
@ -40,12 +50,27 @@ bookmarks-title = Bookmarks
|
||||||
bookmarks-empty = Pages you bookmark appear here.
|
bookmarks-empty = Pages you bookmark appear here.
|
||||||
subscriptions-title = Subscriptions
|
subscriptions-title = Subscriptions
|
||||||
subscriptions-empty = Sites you subscribe to appear here.
|
subscriptions-empty = Sites you subscribe to appear here.
|
||||||
subscriptions-new-one = 1 new post
|
|
||||||
subscriptions-new-many = { $count } new posts
|
## Settings page
|
||||||
settings-history = History
|
|
||||||
settings-bookmarks = Bookmarks
|
settings-page-title = Settings
|
||||||
settings-subscriptions = Subscriptions
|
settings-font-heading = Typeface
|
||||||
settings-library-heading = Library
|
settings-font-atkinson = Atkinson Hyperlegible
|
||||||
|
settings-size-heading = Text size
|
||||||
|
settings-size-decrease = Smaller text
|
||||||
|
settings-size-increase = Larger text
|
||||||
|
# The text size now in use. Format the number in code.
|
||||||
|
settings-size-value = { $percent }%
|
||||||
|
settings-theme-heading = Theme
|
||||||
|
settings-theme-light = Light
|
||||||
|
settings-theme-dark = Dark
|
||||||
|
settings-theme-system = Follow system
|
||||||
|
settings-variant-heading = Colors
|
||||||
|
settings-variant-site = Site
|
||||||
|
settings-variant-warm = Warm
|
||||||
|
settings-variant-cool = Cool
|
||||||
|
settings-variant-green = Green
|
||||||
|
settings-variant-mono = Mono
|
||||||
|
|
||||||
## New posts
|
## New posts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:actors/actors.dart';
|
||||||
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart' show RenderParagraph;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:mews/application/actors/fetch_actor.dart';
|
import 'package:mews/application/actors/fetch_actor.dart';
|
||||||
|
|
@ -13,7 +14,7 @@ import 'package:mews/database/database.dart';
|
||||||
import 'package:mews/l10n/strings.dart';
|
import 'package:mews/l10n/strings.dart';
|
||||||
import 'package:mews/mews/document.dart';
|
import 'package:mews/mews/document.dart';
|
||||||
import 'package:mews/mews/parser.dart';
|
import 'package:mews/mews/parser.dart';
|
||||||
import 'package:mews/application/internal_pages.dart';
|
import 'package:mews/application/pages/mews_urls.dart';
|
||||||
import 'package:mews/presentation/app.dart';
|
import 'package:mews/presentation/app.dart';
|
||||||
import 'package:mews/presentation/reader/address_bar.dart';
|
import 'package:mews/presentation/reader/address_bar.dart';
|
||||||
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
||||||
|
|
@ -107,6 +108,32 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Taps the run reading [label] inside a rendered paragraph. Internal pages
|
||||||
|
/// put several links in one paragraph, so tapping the widget's center would
|
||||||
|
/// hit whichever run happens to sit in the middle.
|
||||||
|
Future<void> tapRun(WidgetTester tester, String label) async {
|
||||||
|
final paragraph = tester.renderObject<RenderParagraph>(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byWidgetPredicate((widget) =>
|
||||||
|
widget is Text &&
|
||||||
|
(widget.textSpan?.toPlainText() ?? widget.data ?? '')
|
||||||
|
.contains(label)),
|
||||||
|
matching: find.byType(RichText),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
final plain = paragraph.text.toPlainText();
|
||||||
|
final start = plain.indexOf(label);
|
||||||
|
final box = paragraph
|
||||||
|
.getBoxesForSelection(TextSelection(
|
||||||
|
baseOffset: start, extentOffset: start + label.length))
|
||||||
|
.first;
|
||||||
|
await tester.tapAt(paragraph.localToGlobal(
|
||||||
|
Offset(box.left + 2, (box.top + box.bottom) / 2)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('renders a page through the full app', (tester) async {
|
testWidgets('renders a page through the full app', (tester) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
|
@ -140,24 +167,27 @@ void main() {
|
||||||
expect(find.textContaining('Try again'), findsOneWidget);
|
expect(find.textContaining('Try again'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the settings page changes presentation and keeps its place',
|
testWidgets('the hub opens the settings page, which applies a choice in place',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
|
|
||||||
// Settings opens from the left address bar icon.
|
// The hub opens from the left address bar icon.
|
||||||
await tester.tap(find.byIcon(Icons.tune));
|
await tester.tap(find.byIcon(Icons.menu));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
|
||||||
|
expect(find.text('Mews'), findsOneWidget);
|
||||||
|
expect(find.textContaining('No bookmarks yet'), findsOneWidget);
|
||||||
|
|
||||||
// Rendered like a Mews page with the option groups.
|
// Its menu reaches settings, which carries the strip of our own pages.
|
||||||
|
await tapRun(tester, 'Settings');
|
||||||
|
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||||
expect(find.text('Settings'), findsOneWidget);
|
expect(find.text('Settings'), findsOneWidget);
|
||||||
expect(find.text('Typeface'), findsOneWidget);
|
expect(find.text('Typeface'), findsOneWidget);
|
||||||
expect(find.text('Theme'), findsOneWidget);
|
expect(find.text('Theme'), findsOneWidget);
|
||||||
expect(find.text('Colors'), findsOneWidget);
|
expect(find.text('Colors'), findsOneWidget);
|
||||||
expect(find.text('Library'), findsOneWidget);
|
// The buried Library section is gone; the strip replaced it.
|
||||||
|
expect(find.text('Library'), findsNothing);
|
||||||
|
|
||||||
// Tapping a theme link applies it and stays on the settings page; the
|
|
||||||
// lower sections scroll into view first.
|
|
||||||
Future<void> scrollTo(Finder target) async {
|
Future<void> scrollTo(Finder target) async {
|
||||||
// Search down the page, then back up for targets above.
|
// Search down the page, then back up for targets above.
|
||||||
for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) {
|
for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) {
|
||||||
|
|
@ -172,38 +202,103 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The option rows stretch the full column width, so tap on the glyphs
|
// Choosing a value applies it and stays put: no new walk history entry.
|
||||||
// themselves rather than the widget's center.
|
|
||||||
Future<void> tapLink(String label) async {
|
|
||||||
final rect = tester.getRect(find.text(label));
|
|
||||||
await tester.tapAt(rect.topLeft + const Offset(30, 13));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
}
|
|
||||||
|
|
||||||
await scrollTo(find.text('Dark'));
|
await scrollTo(find.text('Dark'));
|
||||||
await tapLink('Dark');
|
await tapRun(tester, 'Dark');
|
||||||
expect(deps.settings.themeMode.value, 'dark');
|
expect(deps.settings.themeMode.value, 'dark');
|
||||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||||
expect(deps.browser.canGoBack, isFalse);
|
expect(deps.browser.canGoBack, isTrue);
|
||||||
|
final beforeSize = deps.browser.currentPage.value;
|
||||||
|
|
||||||
await scrollTo(find.text('Warm'));
|
await scrollTo(find.text('Warm'));
|
||||||
await tapLink('Warm');
|
await tapRun(tester, 'Warm');
|
||||||
expect(deps.settings.variant.value, 'warm');
|
expect(deps.settings.variant.value, 'warm');
|
||||||
|
|
||||||
await scrollTo(find.text('Larger text'));
|
await scrollTo(find.text('Larger text'));
|
||||||
await tapLink('Larger text');
|
await tapRun(tester, 'Larger text');
|
||||||
expect(deps.settings.fontScale.value, 1.125);
|
expect(deps.settings.fontScale.value, 1.125);
|
||||||
|
expect(deps.browser.currentPage.value, isNot(same(beforeSize)));
|
||||||
|
|
||||||
// The library links navigate and join the walk history.
|
// The strip navigates and joins the walk history.
|
||||||
await scrollTo(find.text('History'));
|
await scrollTo(find.textContaining('Bookmarks'));
|
||||||
await tapLink('History');
|
await tapRun(tester, 'History');
|
||||||
await tester.pumpAndSettle();
|
expect(deps.browser.currentPage.value?.url, MewsUrls.history);
|
||||||
expect(deps.browser.currentPage.value?.url, InternalPages.historyUrl);
|
|
||||||
expect(deps.browser.canGoBack, isTrue);
|
|
||||||
|
|
||||||
await deps.browser.back();
|
await deps.browser.back();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a fetched page cannot run an action link', (tester) async {
|
||||||
|
final stubDeps = AppDependencies(
|
||||||
|
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||||
|
fetch: _FakeFetch({
|
||||||
|
'https://example.com/evil.html': _page('Evil',
|
||||||
|
'<h1>Evil</h1><p><a href="mews:do/set-theme?value=dark">Go</a></p>'),
|
||||||
|
}),
|
||||||
|
strings: strings,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await stubDeps.browser.open('https://example.com/evil.html');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(stubDeps.settings.themeMode.value, 'system');
|
||||||
|
|
||||||
|
// Following the link from a fetched page changes nothing and goes nowhere
|
||||||
|
// (spec 8.1: page content must not act for the reader).
|
||||||
|
await tapRun(tester, 'Go');
|
||||||
|
expect(stubDeps.settings.themeMode.value, 'system');
|
||||||
|
expect(stubDeps.browser.currentPage.value?.url,
|
||||||
|
'https://example.com/evil.html');
|
||||||
|
|
||||||
|
// The same link works from one of our own pages.
|
||||||
|
await stubDeps.browser.open(MewsUrls.settings);
|
||||||
|
await stubDeps.browser.open('mews:do/set-theme?value=dark');
|
||||||
|
expect(stubDeps.settings.themeMode.value, 'dark');
|
||||||
|
expect(stubDeps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a form on one of our pages navigates instead of fetching',
|
||||||
|
(tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
// The fetch client cannot speak the mews scheme, so a form on an internal
|
||||||
|
// page must be routed before it ever reaches the actor.
|
||||||
|
await deps.browser.open(MewsUrls.bookmarks);
|
||||||
|
await deps.browser.submitForm(MewsUrls.bookmarks, 'get', {'q': 'retro'});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro');
|
||||||
|
expect(deps.browser.error.value, isNull);
|
||||||
|
|
||||||
|
// A form whose action is an action link applies it in place.
|
||||||
|
await deps.browser.open(MewsUrls.settings);
|
||||||
|
await deps.browser.submitForm('mews:do/set-theme', 'get', {'value': 'dark'});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(deps.settings.themeMode.value, 'dark');
|
||||||
|
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||||
|
expect(deps.browser.error.value, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('bare mews: and an unknown name resolve predictably',
|
||||||
|
(tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
await deps.browser.open('mews:');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
|
||||||
|
|
||||||
|
await deps.browser.open('mews:nonsense');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(deps.browser.currentPage.value, isNull);
|
||||||
|
expect(deps.browser.error.value, isA<LoadFailed>());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a session with no history starts at the hub', (tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
await deps.browser.restoreLastSession();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('bookmark and subscribe live on the address bar', (tester) async {
|
testWidgets('bookmark and subscribe live on the address bar', (tester) async {
|
||||||
|
|
|
||||||
11
app/test/flutter_test_config.dart
Normal file
11
app/test/flutter_test_config.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
/// Each test builds its own in-memory database, which is safe because no two
|
||||||
|
/// share an executor, but it trips drift's shared-executor warning and buries
|
||||||
|
/// the rest of the output.
|
||||||
|
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
await testMain();
|
||||||
|
}
|
||||||
98
app/test/mews_urls_test.dart
Normal file
98
app/test/mews_urls_test.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:mews/application/pages/mews_urls.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('isInternal', () {
|
||||||
|
test('recognizes the mews scheme only', () {
|
||||||
|
expect(MewsUrls.isInternal('mews:hub'), isTrue);
|
||||||
|
expect(MewsUrls.isInternal('mews:'), isTrue);
|
||||||
|
expect(MewsUrls.isInternal('https://example.test/'), isFalse);
|
||||||
|
expect(MewsUrls.isInternal('not a url at all'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isAction', () {
|
||||||
|
test('accepts exactly one verb under do/', () {
|
||||||
|
expect(MewsUrls.isAction('mews:do/set-theme?value=dark'), isTrue);
|
||||||
|
expect(MewsUrls.isAction('mews:do/clear-history'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects pages, bare do and deeper paths', () {
|
||||||
|
expect(MewsUrls.isAction('mews:settings'), isFalse);
|
||||||
|
expect(MewsUrls.isAction('mews:do'), isFalse);
|
||||||
|
expect(MewsUrls.isAction('mews:do/a/b'), isFalse);
|
||||||
|
// A fetched page must not reach the dispatcher by mimicking the path.
|
||||||
|
expect(MewsUrls.isAction('https://example.test/do/clear-history'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verbOf reads the verb back', () {
|
||||||
|
expect(MewsUrls.verbOf('mews:do/set-size?step=up'), 'set-size');
|
||||||
|
expect(MewsUrls.verbOf('mews:settings'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('canonical', () {
|
||||||
|
test('bare mews: is the hub', () {
|
||||||
|
expect(MewsUrls.canonical('mews:'), MewsUrls.hub);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a query string and drops a fragment', () {
|
||||||
|
expect(MewsUrls.canonical('mews:bookmarks?tag=phones'),
|
||||||
|
'mews:bookmarks?tag=phones');
|
||||||
|
expect(MewsUrls.canonical('mews:settings#colors'), 'mews:settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves external addresses alone', () {
|
||||||
|
expect(MewsUrls.canonical('https://example.test/a?b=c'),
|
||||||
|
'https://example.test/a?b=c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('action', () {
|
||||||
|
test('builds an address the dispatcher accepts', () {
|
||||||
|
final url = MewsUrls.action('set-theme', {'value': 'dark'});
|
||||||
|
expect(url, 'mews:do/set-theme?value=dark');
|
||||||
|
expect(MewsUrls.isAction(url), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips a url carried inside the query string', () {
|
||||||
|
// Every per-item action names its target this way, so the inner address
|
||||||
|
// must survive encoding untouched.
|
||||||
|
const target = 'https://example.test/p?q=1&r=2';
|
||||||
|
final url = MewsUrls.action('remove-bookmark', {'url': target});
|
||||||
|
expect(Uri.parse(url).queryParameters['url'], target);
|
||||||
|
expect(MewsUrls.isAction(url), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('takes no query string when there are no arguments', () {
|
||||||
|
expect(MewsUrls.action('clear-history'), 'mews:do/clear-history');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('page', () {
|
||||||
|
test('merges over the existing parameters', () {
|
||||||
|
final url = MewsUrls.page(Uri.parse('mews:bookmarks?tag=phones'),
|
||||||
|
{'q': 'retro'});
|
||||||
|
final params = Uri.parse(url).queryParameters;
|
||||||
|
expect(params, {'tag': 'phones', 'q': 'retro'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty value drops the parameter', () {
|
||||||
|
final url =
|
||||||
|
MewsUrls.page(Uri.parse('mews:bookmarks?tag=phones&q=retro'), {'q': ''});
|
||||||
|
expect(Uri.parse(url).queryParameters, {'tag': 'phones'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops the query string once nothing is left', () {
|
||||||
|
final url = MewsUrls.page(Uri.parse('mews:bookmarks?q=retro'), {'q': ''});
|
||||||
|
expect(url, 'mews:bookmarks');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a value needing escapes', () {
|
||||||
|
final url = MewsUrls.page(Uri.parse(MewsUrls.bookmarks), {'q': 'a b&c=d'});
|
||||||
|
expect(Uri.parse(url).queryParameters['q'], 'a b&c=d');
|
||||||
|
expect(MewsUrls.canonical(url), url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,8 @@ void main() {
|
||||||
|
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
expect(find.text('Enter a web address'), findsOneWidget);
|
expect(find.text('Enter a web address'), findsOneWidget);
|
||||||
// Settings opens from the left icon; no floating button remains.
|
// The hub opens from the left icon; no floating button remains.
|
||||||
expect(find.byIcon(Icons.tune), findsOneWidget);
|
expect(find.byIcon(Icons.menu), findsOneWidget);
|
||||||
expect(find.byType(FloatingActionButton), findsNothing);
|
expect(find.byType(FloatingActionButton), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue