feat: retire FOB and sheet; settings as a Mews page and actions on the address bar
This commit is contained in:
parent
6f9c0e587a
commit
28ff7ca327
10 changed files with 324 additions and 361 deletions
|
|
@ -84,6 +84,15 @@ class BrowserController {
|
|||
Future<void> open(String input) async {
|
||||
final url = normalizeAddress(input);
|
||||
if (url.isEmpty) return;
|
||||
|
||||
// Setting links change a value and stay on the current page instead of
|
||||
// joining the walk history.
|
||||
if (InternalPages.isSettingUrl(url)) {
|
||||
await _internalPages?.applySetting(url);
|
||||
await _loadCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
_stack.removeRange(_stackIndex + 1, _stack.length);
|
||||
_stack.add(url);
|
||||
_stackIndex++;
|
||||
|
|
|
|||
|
|
@ -2,26 +2,30 @@ import '../../database/database.dart';
|
|||
import '../../l10n/strings.dart';
|
||||
import '../../mews/document.dart';
|
||||
import 'controllers/reader_settings_controller.dart'
|
||||
show LibraryController;
|
||||
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 Strings strings})
|
||||
: _db = db,
|
||||
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.
|
||||
|
|
@ -41,11 +45,145 @@ class InternalPages {
|
|||
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<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),
|
||||
// The library pages stay reachable now the sheet is gone.
|
||||
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(
|
||||
|
|
@ -114,12 +252,13 @@ class InternalPages {
|
|||
required String title,
|
||||
required String empty,
|
||||
required List<_Entry> entries,
|
||||
List<MewsBlock> extraBlocks = const [],
|
||||
}) {
|
||||
final blocks = <MewsBlock>[
|
||||
HeadingBlock(1, [MewsText(title)]),
|
||||
if (entries.isEmpty)
|
||||
if (entries.isEmpty && extraBlocks.isEmpty)
|
||||
ParagraphBlock([MewsText(empty)])
|
||||
else
|
||||
else if (entries.isNotEmpty)
|
||||
ListBlock(
|
||||
ordered: false,
|
||||
start: 1,
|
||||
|
|
@ -134,6 +273,8 @@ class InternalPages {
|
|||
),
|
||||
];
|
||||
|
||||
blocks.addAll(extraBlocks);
|
||||
|
||||
return MewsPage(
|
||||
url: url,
|
||||
// Internal pages carry a marker so they render as Mews pages, not as
|
||||
|
|
@ -163,3 +304,11 @@ class _Entry {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ class AppDependencies {
|
|||
strings = strings ?? Strings() {
|
||||
settings = ReaderSettingsController(db);
|
||||
library = LibraryController(db);
|
||||
internalPages = InternalPages(db: db, library: library, strings: this.strings);
|
||||
internalPages = InternalPages(
|
||||
db: db, library: library, settings: settings, strings: this.strings);
|
||||
browser = BrowserController(db: db, fetch: this.fetch, internalPages: internalPages);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||
|
||||
import '../../application/internal_pages.dart';
|
||||
import '../app.dart';
|
||||
|
||||
/// The single-field address bar at the top of the reader. Submitting navigates;
|
||||
/// the field shows the current page's URL whenever the reader is not typing.
|
||||
/// The single-field address bar. The left icon opens the settings page; 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});
|
||||
|
||||
|
|
@ -44,10 +46,20 @@ class _AddressBarState extends State<AddressBar> {
|
|||
final strings = AppScope.of(context).strings;
|
||||
|
||||
return SignalBuilder(builder: (context) {
|
||||
final url = deps.browser.currentPage.value?.url;
|
||||
final page = deps.browser.currentPage.value;
|
||||
final url = page?.url;
|
||||
if (url != null && !_focused) {
|
||||
_controller.text = url;
|
||||
}
|
||||
|
||||
// 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) &&
|
||||
(page.atomFeedHref != null || page.entries.isNotEmpty);
|
||||
final canBookmark =
|
||||
page != null && !InternalPages.isInternal(page.url);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
child: TextField(
|
||||
|
|
@ -65,13 +77,50 @@ class _AddressBarState extends State<AddressBar> {
|
|||
decoration: InputDecoration(
|
||||
hintText: strings.t('reader-address-placeholder'),
|
||||
labelText: strings.t('reader-address-label'),
|
||||
prefixIcon: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: strings.t('reader-reload'),
|
||||
onPressed: () => deps.browser.reload(),
|
||||
),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: strings.t('reader-open-settings'),
|
||||
onPressed: () =>
|
||||
deps.browser.open(InternalPages.settingsUrl),
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: strings.t('reader-reload'),
|
||||
onPressed: () => deps.browser.reload(),
|
||||
),
|
||||
if (canSubscribe)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
deps.library.isSubscribed(page.url)
|
||||
? Icons.notifications_active
|
||||
: Icons.notifications_outlined,
|
||||
),
|
||||
tooltip: deps.library.isSubscribed(page.url)
|
||||
? strings.t('settings-unsubscribe')
|
||||
: strings.t('settings-subscribe'),
|
||||
onPressed: () => deps.library
|
||||
.toggleSubscription(page.url, page.title, page.entries),
|
||||
),
|
||||
if (canBookmark)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
deps.library.isBookmarked(page.url)
|
||||
? Icons.star
|
||||
: Icons.star_border,
|
||||
),
|
||||
tooltip: deps.library.isBookmarked(page.url)
|
||||
? strings.t('settings-bookmark-remove')
|
||||
: strings.t('settings-bookmark-add'),
|
||||
onPressed: () =>
|
||||
deps.library.toggleBookmark(page.url, page.title),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The floating control that appears when the reader scrolls, opening the
|
||||
/// reader settings bottom sheet.
|
||||
class Fob extends StatelessWidget {
|
||||
const Fob({required this.visible, required this.label, required this.onTap, super.key});
|
||||
|
||||
final bool visible;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
child: IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Semantics(
|
||||
label: label,
|
||||
button: true,
|
||||
child: FloatingActionButton(
|
||||
onPressed: onTap,
|
||||
child: const Icon(Icons.tune),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,8 @@ import '../../mews/document.dart';
|
|||
import '../../mews/parser.dart' show mewsPlainText;
|
||||
import '../app.dart';
|
||||
import 'address_bar.dart';
|
||||
import 'fob.dart';
|
||||
import 'mews_page_view.dart';
|
||||
import 'palettes.dart';
|
||||
import 'settings_sheet.dart';
|
||||
import 'swipe_navigator.dart';
|
||||
|
||||
/// The whole v1 UI: an address bar that hides on scroll, the page reader with
|
||||
|
|
@ -27,8 +25,8 @@ class ReaderScreen extends StatefulWidget {
|
|||
class _ReaderScreenState extends State<ReaderScreen> {
|
||||
final _scroll = ScrollController();
|
||||
|
||||
/// Address bar and FOB share one visibility: on screen from the start,
|
||||
/// hidden while the reader scrolls down, back on any upward scroll.
|
||||
/// The address bar is on screen from the start, hidden while the reader
|
||||
/// scrolls down, back on any upward scroll.
|
||||
bool _chromeVisible = true;
|
||||
bool _showBrokenText = false;
|
||||
final _reportedNewPosts = <String>{};
|
||||
|
|
@ -63,9 +61,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = AppScope.of(context);
|
||||
final deps = scope.deps;
|
||||
final strings = scope.strings;
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
|
|
@ -116,15 +112,6 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _body(context, page)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Fob(
|
||||
visible: _chromeVisible,
|
||||
label: strings.t('reader-fob-label'),
|
||||
onTap: () => showReaderSettingsSheet(context),
|
||||
),
|
||||
),
|
||||
if (browser.loading.value)
|
||||
const Positioned(
|
||||
top: 8,
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||
|
||||
import '../../application/controllers/reader_settings_controller.dart';
|
||||
import '../../application/internal_pages.dart';
|
||||
import '../app.dart';
|
||||
import 'reader_fonts.dart';
|
||||
|
||||
/// Opens the reader settings bottom sheet: fonts, text size, theme, colors,
|
||||
/// bookmark and subscription for the current page.
|
||||
void showReaderSettingsSheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const SettingsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsSheet extends StatelessWidget {
|
||||
const SettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = AppScope.of(context);
|
||||
final deps = scope.deps;
|
||||
final strings = scope.strings;
|
||||
|
||||
return SignalBuilder(builder: (context) {
|
||||
final settings = deps.settings;
|
||||
final library = deps.library;
|
||||
final page = deps.browser.currentPage.value;
|
||||
final url = page?.url;
|
||||
|
||||
final percent = (settings.fontScale.value * 100).round();
|
||||
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
strings.t('settings-sheet-title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
// The library pages are internal Mews pages; opening one joins
|
||||
// the walk history like any other page.
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(strings.t('settings-history')),
|
||||
onTap: () => _openPage(context, InternalPages.historyUrl),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: Text(strings.t('settings-bookmarks')),
|
||||
onTap: () => _openPage(context, InternalPages.bookmarksUrl),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: Text(strings.t('settings-subscriptions')),
|
||||
onTap: () => _openPage(context, InternalPages.subscriptionsUrl),
|
||||
),
|
||||
const Divider(),
|
||||
_heading(context, strings.t('settings-font-heading')),
|
||||
RadioGroup<String>(
|
||||
groupValue: settings.font.value,
|
||||
onChanged: (value) => settings.setFont(value!),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final font in readerFonts)
|
||||
RadioListTile<String>(
|
||||
value: font.family,
|
||||
title: Text(strings.t(font.labelId)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_heading(context, strings.t('settings-size-heading')),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: strings.t('settings-size-decrease'),
|
||||
onPressed: settings.fontScale.value > ReaderSettingsController.minScale
|
||||
? settings.decreaseSize
|
||||
: null,
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Text(
|
||||
'$percent%',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: strings.t('settings-size-increase'),
|
||||
onPressed: settings.fontScale.value < ReaderSettingsController.maxScale
|
||||
? settings.increaseSize
|
||||
: null,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
_heading(context, strings.t('settings-theme-heading')),
|
||||
RadioGroup<String>(
|
||||
groupValue: settings.themeMode.value,
|
||||
onChanged: (value) => settings.setThemeMode(value!),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (mode, id) in [
|
||||
('light', 'settings-theme-light'),
|
||||
('dark', 'settings-theme-dark'),
|
||||
('system', 'settings-theme-system'),
|
||||
])
|
||||
RadioListTile<String>(
|
||||
value: mode,
|
||||
title: Text(strings.t(id)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_heading(context, strings.t('settings-variant-heading')),
|
||||
RadioGroup<String>(
|
||||
groupValue: settings.variant.value,
|
||||
onChanged: (value) => settings.setVariant(value!),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (variant, id) in [
|
||||
('site', 'settings-variant-site'),
|
||||
('warm', 'settings-variant-warm'),
|
||||
('cool', 'settings-variant-cool'),
|
||||
('green', 'settings-variant-green'),
|
||||
('mono', 'settings-variant-mono'),
|
||||
])
|
||||
RadioListTile<String>(
|
||||
value: variant,
|
||||
title: Text(strings.t(id)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (url != null && !InternalPages.isInternal(url)) ...[
|
||||
ListTile(
|
||||
leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border),
|
||||
title: Text(
|
||||
library.isBookmarked(url)
|
||||
? strings.t('settings-bookmark-remove')
|
||||
: strings.t('settings-bookmark-add'),
|
||||
),
|
||||
onTap: () => library.toggleBookmark(url, page?.title),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(library.isSubscribed(url)
|
||||
? Icons.notifications_active
|
||||
: Icons.notifications_outlined),
|
||||
title: Text(
|
||||
library.isSubscribed(url)
|
||||
? strings.t('settings-unsubscribe')
|
||||
: strings.t('settings-subscribe'),
|
||||
),
|
||||
subtitle: library.isSubscribed(url)
|
||||
? Text(strings.t('settings-subscribed-notice'))
|
||||
: null,
|
||||
onTap: () => library.toggleSubscription(url, page?.title, page?.entries ?? const []),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _openPage(BuildContext context, String url) {
|
||||
Navigator.of(context).pop();
|
||||
AppScope.of(context).deps.browser.open(url);
|
||||
}
|
||||
|
||||
Widget _heading(BuildContext context, String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@ reader-address-placeholder = Enter a web address
|
|||
# Accessibility label for the address field.
|
||||
reader-address-label = Web address
|
||||
reader-reload = Reload this page
|
||||
reader-fob-label = Reading settings
|
||||
reader-open-settings = Open settings
|
||||
|
||||
## Reader settings sheet
|
||||
## Settings page
|
||||
|
||||
settings-sheet-title = Reading settings
|
||||
settings-page-title = Settings
|
||||
settings-font-heading = Typeface
|
||||
settings-font-atkinson = Atkinson Hyperlegible
|
||||
settings-size-heading = Text size
|
||||
|
|
@ -28,7 +28,6 @@ settings-bookmark-add = Add bookmark
|
|||
settings-bookmark-remove = Remove bookmark
|
||||
settings-subscribe = Subscribe to this site
|
||||
settings-unsubscribe = Stop subscribing
|
||||
settings-subscribed-notice = New posts from this site appear here.
|
||||
|
||||
## Library pages
|
||||
|
||||
|
|
@ -44,6 +43,7 @@ subscriptions-new-many = { $count } new posts
|
|||
settings-history = History
|
||||
settings-bookmarks = Bookmarks
|
||||
settings-subscriptions = Subscriptions
|
||||
settings-library-heading = Library
|
||||
|
||||
## New posts
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,13 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:mews/application/actors/fetch_actor.dart';
|
||||
import 'package:mews/application/controllers/browser_controller.dart'
|
||||
show BrokenPage, LoadFailed;
|
||||
import 'package:mews/application/controllers/reader_settings_controller.dart'
|
||||
show ReaderSettingsController;
|
||||
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/presentation/app.dart';
|
||||
import 'package:mews/presentation/reader/address_bar.dart';
|
||||
import 'package:mews/presentation/reader/fob.dart';
|
||||
import 'package:mews/presentation/reader/reader_screen.dart';
|
||||
import 'package:mews/presentation/reader/settings_sheet.dart';
|
||||
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
||||
|
||||
const pageSource = '''
|
||||
|
|
@ -143,53 +139,102 @@ void main() {
|
|||
expect(find.textContaining('Try again'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('settings sheet changes presentation and toggles library',
|
||||
testWidgets('the settings page changes presentation and keeps its place',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
// Settings opens from the left address bar icon.
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
||||
|
||||
// Rendered like a Mews page with the option groups.
|
||||
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);
|
||||
|
||||
// Tapping a theme link applies it and stays on the settings page; the
|
||||
// lower sections scroll into view first.
|
||||
Future<void> scrollTo(Finder target) async {
|
||||
// Search down the page, then back up for targets above.
|
||||
for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) {
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -200));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
for (var i = 0; i < 40 && target.evaluate().isEmpty; i++) {
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 200));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.ensureVisible(target);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// The option rows stretch the full column width, so tap on the glyphs
|
||||
// themselves rather than the widget's center.
|
||||
Future<void> tapLink(String label) async {
|
||||
final rect = tester.getRect(find.text(label));
|
||||
await tester.tapAt(rect.topLeft + const Offset(30, 13));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await scrollTo(find.text('Dark'));
|
||||
await tapLink('Dark');
|
||||
expect(deps.settings.themeMode.value, 'dark');
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
||||
expect(deps.browser.canGoBack, isFalse);
|
||||
|
||||
await scrollTo(find.text('Warm'));
|
||||
await tapLink('Warm');
|
||||
expect(deps.settings.variant.value, 'warm');
|
||||
|
||||
await scrollTo(find.text('Larger text'));
|
||||
await tapLink('Larger text');
|
||||
expect(deps.settings.fontScale.value, 1.125);
|
||||
|
||||
// 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);
|
||||
|
||||
await deps.browser.back();
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
||||
});
|
||||
|
||||
testWidgets('bookmark and subscribe live on the address bar', (tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
// A page with dated entries can be subscribed to.
|
||||
final page = parseSource(pageSource, 'https://example.com/index.html');
|
||||
deps.browser.currentPage.value = page;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
showReaderSettingsSheet(tester.element(find.byType(ReaderScreen)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Typeface, size, theme, colors; the lower sections need scrolling.
|
||||
final sheet = find.byType(Scrollable).last;
|
||||
await tester.tap(find.text('Atkinson Hyperlegible'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(find.text('Dark'), 200, scrollable: sheet);
|
||||
await tester.tap(find.text('Dark'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(find.text('Warm'), 200, scrollable: sheet);
|
||||
await tester.tap(find.text('Warm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(deps.settings.fontScale.value, 1.125);
|
||||
expect(deps.settings.themeMode.value, 'dark');
|
||||
expect(deps.settings.variant.value, 'warm');
|
||||
|
||||
// Bookmark and subscribe the current page; both tiles sit at the bottom
|
||||
// of the sheet, so scroll the sheet down to the end first.
|
||||
await tester.drag(sheet, const Offset(0, -1000));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add bookmark'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue);
|
||||
|
||||
await tester.tap(find.text('Subscribe to this site'));
|
||||
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.notifications_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue);
|
||||
expect(find.byIcon(Icons.notifications_active), findsOneWidget);
|
||||
|
||||
// Settings survive a reload of the controller.
|
||||
final reloaded = ReaderSettingsController(db);
|
||||
await reloaded.load();
|
||||
expect(reloaded.themeMode.value, 'dark');
|
||||
expect(find.byIcon(Icons.star_border), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.star_border));
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue);
|
||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||
|
||||
// Pages with neither Atom feed nor dated links offer no subscription.
|
||||
final plain = parseSource(
|
||||
_page('Plain', '<h1>Plain</h1><p>No entries here.</p>'),
|
||||
'https://example.com/plain.html');
|
||||
deps.browser.currentPage.value = plain;
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.notifications_outlined), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the FOB hides on scroll down and returns on scroll up',
|
||||
testWidgets('the address bar hides on scroll down and returns on scroll up',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
|
|
@ -201,19 +246,16 @@ void main() {
|
|||
deps.browser.currentPage.value = page;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Fob fob() => tester.widget<Fob>(find.byType(Fob));
|
||||
expect(fob().visible, isTrue);
|
||||
expect(find.byType(AddressBar), findsOneWidget);
|
||||
|
||||
// Scrolling down hides the address bar and the FOB.
|
||||
// Scrolling down hides the address bar.
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(fob().visible, isFalse);
|
||||
expect(find.byType(AddressBar), findsNothing);
|
||||
|
||||
// Scrolling up brings both back.
|
||||
// Scrolling up brings it back.
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(fob().visible, isTrue);
|
||||
expect(find.byType(AddressBar), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -260,40 +302,6 @@ void main() {
|
|||
expect(find.text('https://example.com/a.html'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the chrome follows the selected colors', (tester) async {
|
||||
await pumpApp(tester);
|
||||
await deps.settings.setThemeMode('light');
|
||||
await deps.settings.setVariant('warm');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
MaterialApp app() => tester.widget<MaterialApp>(find.byType(MaterialApp));
|
||||
// Warm light palette on the light theme, FOB included.
|
||||
expect(app().theme!.colorScheme.surface, const Color(0xfffbf4e8));
|
||||
expect(app().theme!.colorScheme.primary, const Color(0xff8a3d14));
|
||||
expect(app().theme!.floatingActionButtonTheme.backgroundColor,
|
||||
const Color(0xff8a3d14));
|
||||
expect(app().theme!.textSelectionTheme.selectionColor,
|
||||
const Color(0xff8a3d14).withValues(alpha: 0.3));
|
||||
|
||||
await deps.settings.setThemeMode('dark');
|
||||
await deps.settings.setVariant('green');
|
||||
await tester.pumpAndSettle();
|
||||
// Green dark palette on the dark theme.
|
||||
expect(app().darkTheme!.colorScheme.surface, const Color(0xff161b16));
|
||||
expect(app().darkTheme!.colorScheme.primary, const Color(0xff9fd39a));
|
||||
expect(app().darkTheme!.floatingActionButtonTheme.backgroundColor,
|
||||
const Color(0xff9fd39a));
|
||||
});
|
||||
|
||||
testWidgets('page text is selectable', (tester) async {
|
||||
await pumpApp(tester);
|
||||
final page = parseSource(pageSource, 'https://example.com/index.html');
|
||||
deps.browser.currentPage.value = page;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SelectionArea), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to the wap subdomain before showing an error',
|
||||
(tester) async {
|
||||
final stubDeps = AppDependencies(
|
||||
|
|
@ -375,17 +383,6 @@ void main() {
|
|||
expect(find.textContaining('Page A'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the settings sheet opens the library pages', (tester) async {
|
||||
await pumpApp(tester);
|
||||
showReaderSettingsSheet(tester.element(find.byType(ReaderScreen)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('History'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, 'mews:history');
|
||||
expect(find.text('History'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reports new posts once per page', (tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import 'package:mews/database/database.dart';
|
|||
import 'package:mews/l10n/strings.dart';
|
||||
import 'package:mews/mews/document.dart';
|
||||
import 'package:mews/presentation/app.dart';
|
||||
import 'package:mews/presentation/reader/fob.dart';
|
||||
import 'package:mews/presentation/reader/mews_page_view.dart';
|
||||
import 'package:mews/presentation/reader/palettes.dart';
|
||||
|
||||
|
|
@ -20,16 +19,15 @@ Future<MewsApp> _app() async {
|
|||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('starts with the address bar and the FOB on screen', (tester) async {
|
||||
testWidgets('starts with the address bar and its icons', (tester) async {
|
||||
await tester.pumpWidget(await _app());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Enter a web address'), findsOneWidget);
|
||||
// The FOB is on screen from the beginning and hides only while the
|
||||
// reader scrolls down.
|
||||
final fob = tester.widget<Fob>(find.byType(Fob));
|
||||
expect(fob.visible, isTrue);
|
||||
// Settings opens from the left icon; no floating button remains.
|
||||
expect(find.byIcon(Icons.tune), findsOneWidget);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders a page and follows links', (tester) async {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue