diff --git a/SPEC.md b/SPEC.md index 1782486..fb08bc7 100644 --- a/SPEC.md +++ b/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: -- 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. -- 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//` 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/` 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`. diff --git a/app/lib/application/actors/fetch_actor.dart b/app/lib/application/actors/fetch_actor.dart index 109093f..5a54abb 100644 --- a/app/lib/application/actors/fetch_actor.dart +++ b/app/lib/application/actors/fetch_actor.dart @@ -151,15 +151,21 @@ class FetchHandler with Handler { Future _submitForm(String url, String method, Map fields) async { final headers = {'User-Agent': _userAgent}; - http.Response response; - if (method == 'post') { - response = await _client - .post(Uri.parse(url), headers: headers, body: fields) - .timeout(const Duration(seconds: 20)); - } else { - response = await _client - .get(Uri.parse(url).replace(queryParameters: fields), headers: headers) - .timeout(const Duration(seconds: 20)); + final http.Response response; + try { + if (method == 'post') { + response = await _client + .post(Uri.parse(url), headers: headers, body: fields) + .timeout(const Duration(seconds: 20)); + } else { + response = await _client + .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); return _parse(response.body, url); diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index ad33f10..d7a0dce 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -5,7 +5,9 @@ import 'package:signals/signals.dart'; import '../../database/database.dart'; import '../../mews/document.dart'; import '../actors/fetch_actor.dart'; +import '../internal_actions.dart'; import '../internal_pages.dart'; +import '../pages/mews_urls.dart'; /// What went wrong while browsing. The presentation layer maps these to /// user-facing messages; no raw codes cross into the UI. @@ -32,13 +34,16 @@ class BrowserController { required MewsDatabase db, required Actor fetch, InternalPages? internalPages, + InternalActions? internalActions, }) : _db = db, _fetch = fetch, - _internalPages = internalPages; + _internalPages = internalPages, + _internalActions = internalActions; final MewsDatabase _db; final Actor _fetch; final InternalPages? _internalPages; + final InternalActions? _internalActions; /// The stack of visited pages; the reader is at [_stackIndex]. final List _stack = []; @@ -82,14 +87,18 @@ class BrowserController { } Future open(String input) async { - final url = normalizeAddress(input); + final url = MewsUrls.canonical(normalizeAddress(input)); 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. - if (InternalPages.isSettingUrl(url)) { - await _internalPages?.applySetting(url); - await _loadCurrent(); + if (MewsUrls.isAction(url)) { + // Only the client's own pages may change the reader's data. A fetched + // 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; } @@ -145,10 +154,39 @@ class BrowserController { Future 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 refreshInternalPage() async { + if (!_onInternalPage) return; + await _loadCurrent(); + } + Future submitForm(String action, String method, Map 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; 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; switch (result) { 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 restoreLastSession() async { - final url = await _db.lastVisitedUrl(); - if (url == null) return; + final url = await _db.lastVisitedUrl() ?? MewsUrls.hub; _stack.add(url); _stackIndex = 0; _updateNeighbors(); @@ -216,7 +254,8 @@ class BrowserController { if (page != null) { currentPage.value = page; 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 { currentPage.value = null; error.value = const LoadFailed(); diff --git a/app/lib/application/controllers/reader_settings_controller.dart b/app/lib/application/controllers/reader_settings_controller.dart index cc62fa9..9614dbd 100644 --- a/app/lib/application/controllers/reader_settings_controller.dart +++ b/app/lib/application/controllers/reader_settings_controller.dart @@ -16,10 +16,16 @@ class ReaderSettingsController { static const maxScale = 2.0; 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 fontScale = signal(1.0); - final themeMode = signal('system'); // system | light | dark - final variant = signal('site'); // site | warm | cool | green | mono + final themeMode = signal('system'); + final variant = signal('site'); Future load() async { final settings = await _db.readSettings(); @@ -30,28 +36,37 @@ class ReaderSettingsController { variant.value = settings.variant; } - Future setFont(String family) async { + Future setFont(String family) async { + if (family.isEmpty || family == font.value) return false; font.value = family; await _persist(); + return true; } - Future decreaseSize() => _setScale(fontScale.value - scaleStep); - Future increaseSize() => _setScale(fontScale.value + scaleStep); + Future decreaseSize() => _setScale(fontScale.value - scaleStep); + Future increaseSize() => _setScale(fontScale.value + scaleStep); - Future _setScale(double value) async { - if (value < minScale || value > maxScale) return; + Future _setScale(double value) async { + if (value < minScale || value > maxScale) return false; fontScale.value = value; await _persist(); + return true; } - Future setThemeMode(String mode) async { + Future setThemeMode(String mode) async { + if (!themeModes.contains(mode) || mode == themeMode.value) return false; themeMode.value = mode; await _persist(); + return true; } - Future setVariant(String variant) async { + Future setVariant(String variant) async { + if (!variants.contains(variant) || variant == this.variant.value) { + return false; + } this.variant.value = variant; await _persist(); + return true; } Future _persist() => _db.saveSettings( diff --git a/app/lib/application/internal_actions.dart b/app/lib/application/internal_actions.dart new file mode 100644 index 0000000..0127e71 --- /dev/null +++ b/app/lib/application/internal_actions.dart @@ -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 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 _setSize(String? step) => switch (step) { + 'up' => _settings.increaseSize(), + 'down' => _settings.decreaseSize(), + _ => Future.value(false), + }; +} diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index e244425..fb9352d 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -1,12 +1,18 @@ -import '../../database/database.dart'; -import '../../l10n/strings.dart'; -import '../../mews/document.dart'; +import '../database/database.dart'; +import '../l10n/strings.dart'; +import '../mews/document.dart'; import 'controllers/reader_settings_controller.dart' 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 -/// [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. +/// Builds the client's own 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, @@ -18,296 +24,27 @@ class InternalPages { _settings = settings, _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 LibraryController _library; final ReaderSettingsController _settings; 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'; + bool handles(String url) => MewsUrls.isInternal(url); /// The internal page for [url], or null when the name is unknown. - Future 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); - case 'settings': - return _settingsPage(url); - default: - return null; - } + Future pageFor(String url) { + // Internal addresses are opaque, so the page name is the path, not a host. + final uri = Uri.parse(MewsUrls.canonical(url)); + return switch (uri.path) { + 'hub' => buildHubPage(uri, db: _db, strings: _strings), + 'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings), + 'subscriptions' => buildSubscriptionsPage(uri, + db: _db, library: _library, strings: _strings), + 'history' => buildHistoryPage(uri, db: _db, strings: _strings), + 'settings' => + buildSettingsPage(uri, settings: _settings, strings: _strings), + _ => Future.value(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 applySetting(String url) async { - final segments = - Uri.tryParse(url)?.path.split('/') ?? const []; - 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 _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 _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 _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 _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 extraBlocks = const [], - }) { - final blocks = [ - 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; } diff --git a/app/lib/application/pages/bookmarks_page.dart b/app/lib/application/pages/bookmarks_page.dart new file mode 100644 index 0000000..bfe3136 --- /dev/null +++ b/app/lib/application/pages/bookmarks_page.dart @@ -0,0 +1,32 @@ +import '../../database/database.dart'; +import '../../l10n/strings.dart'; +import '../../mews/document.dart'; +import 'page_blocks.dart'; + +Future 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), + ), + ]), + ], + ); +} diff --git a/app/lib/application/pages/history_page.dart b/app/lib/application/pages/history_page.dart new file mode 100644 index 0000000..f244a7b --- /dev/null +++ b/app/lib/application/pages/history_page.dart @@ -0,0 +1,32 @@ +import '../../database/database.dart'; +import '../../l10n/strings.dart'; +import '../../mews/document.dart'; +import 'page_blocks.dart'; + +Future 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), + ), + ]), + ], + ); +} diff --git a/app/lib/application/pages/hub_page.dart b/app/lib/application/pages/hub_page.dart new file mode 100644 index 0000000..fcc0092 --- /dev/null +++ b/app/lib/application/pages/hub_page.dart @@ -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 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')), + ]), + ], + ); +} diff --git a/app/lib/application/pages/mews_urls.dart b/app/lib/application/pages/mews_urls.dart new file mode 100644 index 0000000..2493773 --- /dev/null +++ b/app/lib/application/pages/mews_urls.dart @@ -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 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 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(); + } +} diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart new file mode 100644 index 0000000..6b2e0af --- /dev/null +++ b/app/lib/application/pages/page_blocks.dart @@ -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 = [ + (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 chrome({ + required String title, + required Uri current, + required Strings strings, +}) { + final inline = []; + 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 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 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 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'); diff --git a/app/lib/application/pages/settings_page.dart b/app/lib/application/pages/settings_page.dart new file mode 100644 index 0000000..2d4d7ac --- /dev/null +++ b/app/lib/application/pages/settings_page.dart @@ -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 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, + ), + ]), + ], + ); +} diff --git a/app/lib/application/pages/subscriptions_page.dart b/app/lib/application/pages/subscriptions_page.dart new file mode 100644 index 0000000..b758d5a --- /dev/null +++ b/app/lib/application/pages/subscriptions_page.dart @@ -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 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'); +} diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 182566e..26a0b4a 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -91,6 +91,19 @@ class MewsDatabase extends _$MewsDatabase { return row?.url; } + /// Row counts for the hub, so it can say what each page holds without + /// reading every row. + Future countHistory() => _countOf(historyItems, historyItems.id); + Future countBookmarks() => _countOf(bookmarks, bookmarks.id); + Future countSubscriptions() => _countOf(subscriptions, subscriptions.id); + + Future _countOf(ResultSetImplementation table, + GeneratedColumn 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. Future> recentVisits({int limit = 50}) async { final query = (select(historyItems) diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index 2ff92fc..52c37fd 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../application/actors/fetch_actor.dart'; import '../application/controllers/browser_controller.dart'; import '../application/controllers/reader_settings_controller.dart'; +import '../application/internal_actions.dart'; import '../application/internal_pages.dart'; import '../database/database.dart'; import '../l10n/strings.dart'; @@ -26,7 +27,13 @@ class AppDependencies { library = LibraryController(db); internalPages = InternalPages( 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. @@ -38,6 +45,7 @@ class AppDependencies { late final ReaderSettingsController settings; late final LibraryController library; late final InternalPages internalPages; + late final InternalActions internalActions; Future start() async { await settings.load(); diff --git a/app/lib/presentation/reader/address_bar.dart b/app/lib/presentation/reader/address_bar.dart index efe1339..ad4453b 100644 --- a/app/lib/presentation/reader/address_bar.dart +++ b/app/lib/presentation/reader/address_bar.dart @@ -2,13 +2,13 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatf import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; -import '../../application/internal_pages.dart'; +import '../../application/pages/mews_urls.dart'; import '../app.dart'; -/// The single-field address bar. The left icon opens the settings page and, -/// on pointer platforms, carries the back and forward buttons; the right -/// icons reload, subscribe (only when the page offers something to subscribe -/// to) and bookmark the current page. +/// The single-field address bar. The left icon opens the hub and, on pointer +/// platforms, carries the back and forward buttons; the right icons reload, +/// subscribe (only when the page offers something to subscribe to) and +/// bookmark the current page. class AddressBar extends StatefulWidget { const AddressBar({super.key}); @@ -65,10 +65,17 @@ class _AddressBarState extends State { // Only pages with dated entry links or an Atom feed can be subscribed // to (mews spec, section 6). final canSubscribe = page != null && - !InternalPages.isInternal(page.url) && + !MewsUrls.isInternal(page.url) && (page.atomFeedHref != null || page.entries.isNotEmpty); - final canBookmark = - page != null && !InternalPages.isInternal(page.url); + final canBookmark = page != null && !MewsUrls.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( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), @@ -93,12 +100,7 @@ class _AddressBarState extends State { ? Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.tune), - tooltip: strings.t('reader-open-settings'), - onPressed: () => - deps.browser.open(InternalPages.settingsUrl), - ), + hubButton, IconButton( icon: const Icon(Icons.arrow_back), tooltip: strings.t('reader-back'), @@ -115,12 +117,7 @@ class _AddressBarState extends State { ), ], ) - : IconButton( - icon: const Icon(Icons.tune), - tooltip: strings.t('reader-open-settings'), - onPressed: () => - deps.browser.open(InternalPages.settingsUrl), - ), + : hubButton, prefixIconConstraints: hasButtons ? const BoxConstraints(minWidth: 138, minHeight: 48) : null, @@ -140,8 +137,8 @@ class _AddressBarState extends State { : Icons.notifications_outlined, ), tooltip: deps.library.isSubscribed(page.url) - ? strings.t('settings-unsubscribe') - : strings.t('settings-subscribe'), + ? strings.t('reader-unsubscribe') + : strings.t('reader-subscribe'), onPressed: () => deps.library .toggleSubscription(page.url, page.title, page.entries), ), @@ -153,8 +150,8 @@ class _AddressBarState extends State { : Icons.star_border, ), tooltip: deps.library.isBookmarked(page.url) - ? strings.t('settings-bookmark-remove') - : strings.t('settings-bookmark-add'), + ? strings.t('reader-bookmark-remove') + : strings.t('reader-bookmark-add'), onPressed: () => deps.library.toggleBookmark(page.url, page.title), ), diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 50b8949..3e00b7f 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -4,32 +4,42 @@ reader-address-placeholder = Enter a web address # Accessibility label for the address field. reader-address-label = Web address reader-reload = Reload this page -reader-open-settings = Open settings +reader-open-hub = Open your Mews pages reader-back = Go back 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 -settings-font-heading = Typeface -settings-font-atkinson = Atkinson Hyperlegible -settings-size-heading = Text size -settings-size-decrease = Smaller text -settings-size-increase = Larger text -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 -settings-bookmark-add = Add bookmark -settings-bookmark-remove = Remove bookmark -settings-subscribe = Subscribe to this site -settings-unsubscribe = Stop subscribing +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-bookmarks = Bookmarks +nav-subscriptions = Subscriptions +nav-history = History +nav-settings = Settings + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = No bookmarks yet +count-bookmarks-one = 1 bookmark +count-bookmarks-many = { $count } bookmarks +count-sites-none = No sites yet +count-sites-one = 1 site +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 @@ -40,12 +50,27 @@ bookmarks-title = Bookmarks bookmarks-empty = Pages you bookmark appear here. subscriptions-title = Subscriptions subscriptions-empty = Sites you subscribe to appear here. -subscriptions-new-one = 1 new post -subscriptions-new-many = { $count } new posts -settings-history = History -settings-bookmarks = Bookmarks -settings-subscriptions = Subscriptions -settings-library-heading = Library + +## Settings page + +settings-page-title = Settings +settings-font-heading = Typeface +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 diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 34c869f..ab2df02 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -4,6 +4,7 @@ import 'package:actors/actors.dart'; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter_test/flutter_test.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/mews/document.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/reader/address_bar.dart'; import 'package:mews/presentation/reader/swipe_navigator.dart'; @@ -107,6 +108,32 @@ void main() { 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 tapRun(WidgetTester tester, String label) async { + final paragraph = tester.renderObject( + 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 { await pumpApp(tester); @@ -140,24 +167,27 @@ void main() { 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 { await pumpApp(tester); - // Settings opens from the left address bar icon. - await tester.tap(find.byIcon(Icons.tune)); + // The hub opens from the left address bar icon. + await tester.tap(find.byIcon(Icons.menu)); 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('Typeface'), findsOneWidget); expect(find.text('Theme'), 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 scrollTo(Finder target) async { // Search down the page, then back up for targets above. for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) { @@ -172,38 +202,103 @@ void main() { await tester.pumpAndSettle(); } - // The option rows stretch the full column width, so tap on the glyphs - // themselves rather than the widget's center. - Future tapLink(String label) async { - final rect = tester.getRect(find.text(label)); - await tester.tapAt(rect.topLeft + const Offset(30, 13)); - await tester.pumpAndSettle(); - } - + // Choosing a value applies it and stays put: no new walk history entry. await scrollTo(find.text('Dark')); - await tapLink('Dark'); + await tapRun(tester, 'Dark'); expect(deps.settings.themeMode.value, 'dark'); - expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); - expect(deps.browser.canGoBack, isFalse); + expect(deps.browser.currentPage.value?.url, MewsUrls.settings); + expect(deps.browser.canGoBack, isTrue); + final beforeSize = deps.browser.currentPage.value; await scrollTo(find.text('Warm')); - await tapLink('Warm'); + await tapRun(tester, 'Warm'); expect(deps.settings.variant.value, 'warm'); await scrollTo(find.text('Larger text')); - await tapLink('Larger text'); + await tapRun(tester, 'Larger text'); expect(deps.settings.fontScale.value, 1.125); + expect(deps.browser.currentPage.value, isNot(same(beforeSize))); - // The library links navigate and join the walk history. - await scrollTo(find.text('History')); - await tapLink('History'); - await tester.pumpAndSettle(); - expect(deps.browser.currentPage.value?.url, InternalPages.historyUrl); - expect(deps.browser.canGoBack, isTrue); + // The strip navigates and joins the walk history. + await scrollTo(find.textContaining('Bookmarks')); + await tapRun(tester, 'History'); + expect(deps.browser.currentPage.value?.url, MewsUrls.history); await deps.browser.back(); 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', + '

Evil

Go

'), + }), + 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()); + }); + + 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 { diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart new file mode 100644 index 0000000..513691f --- /dev/null +++ b/app/test/flutter_test_config.dart @@ -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 testExecutable(FutureOr Function() testMain) async { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + await testMain(); +} diff --git a/app/test/mews_urls_test.dart b/app/test/mews_urls_test.dart new file mode 100644 index 0000000..92b4b51 --- /dev/null +++ b/app/test/mews_urls_test.dart @@ -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); + }); + }); +} diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart index b43a3c1..436a7f8 100644 --- a/app/test/reader_widget_test.dart +++ b/app/test/reader_widget_test.dart @@ -25,8 +25,8 @@ void main() { expect(find.byType(TextField), findsOneWidget); expect(find.text('Enter a web address'), findsOneWidget); - // Settings opens from the left icon; no floating button remains. - expect(find.byIcon(Icons.tune), findsOneWidget); + // The hub opens from the left icon; no floating button remains. + expect(find.byIcon(Icons.menu), findsOneWidget); expect(find.byType(FloatingActionButton), findsNothing); });