feat: retire FOB and sheet; settings as a Mews page and actions on the address bar

This commit is contained in:
randogoth 2026-10-11 12:14:32 +03:00
parent 6f9c0e587a
commit 28ff7ca327
10 changed files with 324 additions and 361 deletions

View file

@ -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++;

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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),
),
],
),
),
),
);

View file

@ -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),
),
),
),
),
);
}
}

View file

@ -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,

View file

@ -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),
),
);
}

View file

@ -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

View file

@ -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);

View file

@ -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 {