2026-10-11 11:48:24 +03:00
|
|
|
import '../../database/database.dart';
|
|
|
|
|
import '../../l10n/strings.dart';
|
|
|
|
|
import '../../mews/document.dart';
|
|
|
|
|
import 'controllers/reader_settings_controller.dart'
|
2026-10-11 12:14:32 +03:00
|
|
|
show LibraryController, ReaderSettingsController;
|
2026-10-11 11:48:24 +03:00
|
|
|
|
|
|
|
|
/// Builds the internal History, Bookmarks and Subscriptions pages as real
|
|
|
|
|
/// [MewsPage]s: they render like any other page and take part in the walk
|
|
|
|
|
/// history, so swiping and back/forward treat them like visited pages.
|
|
|
|
|
class InternalPages {
|
2026-10-11 12:14:32 +03:00
|
|
|
InternalPages({
|
|
|
|
|
required MewsDatabase db,
|
|
|
|
|
required LibraryController library,
|
|
|
|
|
required ReaderSettingsController settings,
|
|
|
|
|
required Strings strings,
|
|
|
|
|
}) : _db = db,
|
2026-10-11 11:48:24 +03:00
|
|
|
_library = library,
|
2026-10-11 12:14:32 +03:00
|
|
|
_settings = settings,
|
2026-10-11 11:48:24 +03:00
|
|
|
_strings = strings;
|
|
|
|
|
|
|
|
|
|
static const historyUrl = 'mews:history';
|
|
|
|
|
static const bookmarksUrl = 'mews:bookmarks';
|
|
|
|
|
static const subscriptionsUrl = 'mews:subscriptions';
|
2026-10-11 12:14:32 +03:00
|
|
|
static const settingsUrl = 'mews:settings';
|
2026-10-11 11:48:24 +03:00
|
|
|
|
|
|
|
|
final MewsDatabase _db;
|
|
|
|
|
final LibraryController _library;
|
2026-10-11 12:14:32 +03:00
|
|
|
final ReaderSettingsController _settings;
|
2026-10-11 11:48:24 +03:00
|
|
|
final Strings _strings;
|
|
|
|
|
|
|
|
|
|
/// Whether [url] is one of the internal pages.
|
|
|
|
|
bool handles(String url) => isInternal(url);
|
|
|
|
|
|
|
|
|
|
/// Whether [url] belongs to the internal mews: scheme.
|
|
|
|
|
static bool isInternal(String url) =>
|
|
|
|
|
Uri.tryParse(url)?.scheme == 'mews';
|
|
|
|
|
|
|
|
|
|
/// The internal page for [url], or null when the name is unknown.
|
|
|
|
|
Future<MewsPage?> pageFor(String url) async {
|
|
|
|
|
// mews:history is an opaque URI: the page name is the path, not a host.
|
|
|
|
|
switch (Uri.tryParse(url)?.path) {
|
|
|
|
|
case 'history':
|
|
|
|
|
return _history(url);
|
|
|
|
|
case 'bookmarks':
|
|
|
|
|
return _bookmarks(url);
|
|
|
|
|
case 'subscriptions':
|
|
|
|
|
return _subscriptions(url);
|
2026-10-11 12:14:32 +03:00
|
|
|
case 'settings':
|
|
|
|
|
return _settingsPage(url);
|
2026-10-11 11:48:24 +03:00
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 12:14:32 +03:00
|
|
|
/// 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)]);
|
|
|
|
|
|
2026-10-11 11:48:24 +03:00
|
|
|
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,
|
2026-10-11 12:14:32 +03:00
|
|
|
List<MewsBlock> extraBlocks = const [],
|
2026-10-11 11:48:24 +03:00
|
|
|
}) {
|
|
|
|
|
final blocks = <MewsBlock>[
|
|
|
|
|
HeadingBlock(1, [MewsText(title)]),
|
2026-10-11 12:14:32 +03:00
|
|
|
if (entries.isEmpty && extraBlocks.isEmpty)
|
2026-10-11 11:48:24 +03:00
|
|
|
ParagraphBlock([MewsText(empty)])
|
2026-10-11 12:14:32 +03:00
|
|
|
else if (entries.isNotEmpty)
|
2026-10-11 11:48:24 +03:00
|
|
|
ListBlock(
|
|
|
|
|
ordered: false,
|
|
|
|
|
start: 1,
|
|
|
|
|
items: [
|
|
|
|
|
for (final entry in entries)
|
|
|
|
|
MewsListItem([
|
|
|
|
|
ParagraphBlock([
|
|
|
|
|
MewsLink(entry.url, [MewsText('${entry.label} ${entry.title}')]),
|
|
|
|
|
]),
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
2026-10-11 12:14:32 +03:00
|
|
|
blocks.addAll(extraBlocks);
|
|
|
|
|
|
2026-10-11 11:48:24 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-10-11 12:14:32 +03:00
|
|
|
|
|
|
|
|
/// One selectable value in a settings option group.
|
|
|
|
|
class _Option {
|
|
|
|
|
const _Option(this.label, this.value);
|
|
|
|
|
|
|
|
|
|
final String label;
|
|
|
|
|
final String value;
|
|
|
|
|
}
|