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
|
|
@ -151,15 +151,21 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
|
|||
|
||||
Future<FetchResult> _submitForm(String url, String method, Map<String, String> 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);
|
||||
|
|
|
|||
|
|
@ -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<FetchCommand, FetchResult> fetch,
|
||||
InternalPages? internalPages,
|
||||
InternalActions? internalActions,
|
||||
}) : _db = db,
|
||||
_fetch = fetch,
|
||||
_internalPages = internalPages;
|
||||
_internalPages = internalPages,
|
||||
_internalActions = internalActions;
|
||||
|
||||
final MewsDatabase _db;
|
||||
final Actor<FetchCommand, FetchResult> _fetch;
|
||||
final InternalPages? _internalPages;
|
||||
final InternalActions? _internalActions;
|
||||
|
||||
/// The stack of visited pages; the reader is at [_stackIndex].
|
||||
final List<String> _stack = [];
|
||||
|
|
@ -82,14 +87,18 @@ class BrowserController {
|
|||
}
|
||||
|
||||
Future<void> 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<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 {
|
||||
// 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<void> 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();
|
||||
|
|
|
|||
|
|
@ -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<void> load() async {
|
||||
final settings = await _db.readSettings();
|
||||
|
|
@ -30,28 +36,37 @@ class ReaderSettingsController {
|
|||
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;
|
||||
await _persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> decreaseSize() => _setScale(fontScale.value - scaleStep);
|
||||
Future<void> increaseSize() => _setScale(fontScale.value + scaleStep);
|
||||
Future<bool> decreaseSize() => _setScale(fontScale.value - scaleStep);
|
||||
Future<bool> increaseSize() => _setScale(fontScale.value + scaleStep);
|
||||
|
||||
Future<void> _setScale(double value) async {
|
||||
if (value < minScale || value > maxScale) return;
|
||||
Future<bool> _setScale(double value) async {
|
||||
if (value < minScale || value > maxScale) return false;
|
||||
fontScale.value = value;
|
||||
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;
|
||||
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;
|
||||
await _persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
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 '../../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<MewsPage?> pageFor(String url) async {
|
||||
// mews:history is an opaque URI: the page name is the path, not a host.
|
||||
switch (Uri.tryParse(url)?.path) {
|
||||
case 'history':
|
||||
return _history(url);
|
||||
case 'bookmarks':
|
||||
return _bookmarks(url);
|
||||
case 'subscriptions':
|
||||
return _subscriptions(url);
|
||||
case 'settings':
|
||||
return _settingsPage(url);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
Future<MewsPage?> 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<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;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<List<HistoryItem>> recentVisits({int limit = 50}) async {
|
||||
final query = (select(historyItems)
|
||||
|
|
|
|||
|
|
@ -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<void> start() async {
|
||||
await settings.load();
|
||||
|
|
|
|||
|
|
@ -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<AddressBar> {
|
|||
// 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<AddressBar> {
|
|||
? 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<AddressBar> {
|
|||
),
|
||||
],
|
||||
)
|
||||
: 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<AddressBar> {
|
|||
: 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<AddressBar> {
|
|||
: 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),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue