import '../../database/database.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; import 'controllers/reader_settings_controller.dart' show LibraryController, ReaderSettingsController; /// Builds the internal History, Bookmarks and Subscriptions pages as real /// [MewsPage]s: they render like any other page and take part in the walk /// history, so swiping and back/forward treat them like visited pages. class InternalPages { InternalPages({ required MewsDatabase db, required LibraryController library, required ReaderSettingsController settings, required Strings strings, }) : _db = db, _library = library, _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'; /// 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; } } /// 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; }