fix: mount StringsScope, keep list inline text, show FOB until scroll down
This commit is contained in:
parent
9a11194f77
commit
65e693b9c6
6 changed files with 281 additions and 27 deletions
|
|
@ -156,9 +156,21 @@ void _appendBlocks(XmlElement element, String pageUrl, List<MewsBlock> out) {
|
||||||
out.addAll(embedded);
|
out.addAll(embedded);
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
final children = <MewsBlock>[];
|
final children = <MewsBlock>[];
|
||||||
for (final child in element.children.whereType<XmlElement>()) {
|
final embedded = <MewsBlock>[];
|
||||||
_appendBlocks(child, pageUrl, children);
|
// Bare inline content (quotes are often written as plain text) becomes
|
||||||
|
// a paragraph, like in list items.
|
||||||
|
final inline = <MewsInline>[];
|
||||||
|
for (final node in element.children) {
|
||||||
|
final isInlineNode = node is XmlText ||
|
||||||
|
(node is XmlElement && _inlineElements.contains(node.name.qualified));
|
||||||
|
if (isInlineNode) {
|
||||||
|
_appendInline(node, pageUrl, inline, embedded);
|
||||||
|
} else if (node is XmlElement) {
|
||||||
|
_appendBlocks(node, pageUrl, children);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (inline.isNotEmpty) children.insert(0, ParagraphBlock(inline));
|
||||||
|
children.addAll(embedded);
|
||||||
out.add(QuoteBlock(children));
|
out.add(QuoteBlock(children));
|
||||||
case 'pre':
|
case 'pre':
|
||||||
out.add(PreBlock(element.innerText));
|
out.add(PreBlock(element.innerText));
|
||||||
|
|
@ -193,13 +205,33 @@ void _appendBlocks(XmlElement element, String pageUrl, List<MewsBlock> out) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inline-only elements; text and images directly inside these become part of
|
||||||
|
/// the enclosing paragraph's inline run.
|
||||||
|
const _inlineElements = {
|
||||||
|
'a', 'br', 'em', 'strong', 'b', 'i', 'code', 'kbd', 'samp', 'var', 'q',
|
||||||
|
'cite', 'abbr', 'dfn', 'img',
|
||||||
|
};
|
||||||
|
|
||||||
ListBlock _listBlock(XmlElement element, String pageUrl) {
|
ListBlock _listBlock(XmlElement element, String pageUrl) {
|
||||||
final items = <MewsListItem>[];
|
final items = <MewsListItem>[];
|
||||||
for (final li in element.findElements('li')) {
|
for (final li in element.findElements('li')) {
|
||||||
final blocks = <MewsBlock>[];
|
final blocks = <MewsBlock>[];
|
||||||
for (final child in li.children.whereType<XmlElement>()) {
|
final embedded = <MewsBlock>[];
|
||||||
_appendBlocks(child, pageUrl, blocks);
|
// List items commonly hold bare inline content (the dated-link entry
|
||||||
|
// pattern in spec 6.1), so direct text and inline elements form a
|
||||||
|
// paragraph while block-level children append as blocks.
|
||||||
|
final inline = <MewsInline>[];
|
||||||
|
for (final node in li.children) {
|
||||||
|
final isInlineNode = node is XmlText ||
|
||||||
|
(node is XmlElement && _inlineElements.contains(node.name.qualified));
|
||||||
|
if (isInlineNode) {
|
||||||
|
_appendInline(node, pageUrl, inline, embedded);
|
||||||
|
} else if (node is XmlElement) {
|
||||||
|
_appendBlocks(node, pageUrl, blocks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (inline.isNotEmpty) blocks.insert(0, ParagraphBlock(inline));
|
||||||
|
blocks.addAll(embedded);
|
||||||
items.add(MewsListItem(blocks));
|
items.add(MewsListItem(blocks));
|
||||||
}
|
}
|
||||||
return ListBlock(
|
return ListBlock(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import '../application/controllers/reader_settings_controller.dart';
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
import '../l10n/strings.dart';
|
import '../l10n/strings.dart';
|
||||||
import 'reader/reader_screen.dart';
|
import 'reader/reader_screen.dart';
|
||||||
|
|
||||||
/// Wires the app's dependencies together and provides them to the tree.
|
/// Wires the app's dependencies together and provides them to the tree.
|
||||||
/// Everything is local-first: the database is created up front and the fetch
|
/// Everything is local-first: the database is created up front and the fetch
|
||||||
/// actor is the only path to the network.
|
/// actor is the only path to the network.
|
||||||
|
|
@ -63,7 +62,10 @@ class MewsApp extends StatelessWidget {
|
||||||
return AppScope(
|
return AppScope(
|
||||||
deps: deps,
|
deps: deps,
|
||||||
strings: strings,
|
strings: strings,
|
||||||
child: const _ThemedApp(),
|
child: StringsScope(
|
||||||
|
strings: strings,
|
||||||
|
child: const _ThemedApp(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ class ReaderScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _ReaderScreenState extends State<ReaderScreen> {
|
class _ReaderScreenState extends State<ReaderScreen> {
|
||||||
final _scroll = ScrollController();
|
final _scroll = ScrollController();
|
||||||
bool _barVisible = true;
|
|
||||||
bool _fobVisible = false;
|
/// Address bar and FOB share one visibility: on screen from the start,
|
||||||
|
/// hidden while the reader scrolls down, back on any upward scroll.
|
||||||
|
bool _chromeVisible = true;
|
||||||
bool _showBrokenText = false;
|
bool _showBrokenText = false;
|
||||||
final _reportedNewPosts = <String>{};
|
final _reportedNewPosts = <String>{};
|
||||||
final _checkedSubscriptions = <String>{};
|
final _checkedSubscriptions = <String>{};
|
||||||
|
|
@ -48,20 +50,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
||||||
if (!_scroll.hasClients) return;
|
if (!_scroll.hasClients) return;
|
||||||
final position = _scroll.position;
|
final position = _scroll.position;
|
||||||
|
|
||||||
// The bar hides when the reader scrolls down and comes back on any
|
// The bar and FOB hide when the reader scrolls down and come back on any
|
||||||
// upward scroll; it is always visible at the top of the page.
|
// upward scroll; both are always visible at the top of the page.
|
||||||
final wantBar = position.pixels <= 0 ||
|
final wantVisible = position.pixels <= 0 ||
|
||||||
position.userScrollDirection == ScrollDirection.forward;
|
position.userScrollDirection == ScrollDirection.forward;
|
||||||
// The FOB appears once the page is scrolled, and stays reachable on pages
|
|
||||||
// that fit without scrolling.
|
|
||||||
final contentScrolls = position.maxScrollExtent > 0;
|
|
||||||
final wantFob = contentScrolls ? position.pixels > 16 : true;
|
|
||||||
|
|
||||||
if (wantBar != _barVisible || wantFob != _fobVisible) {
|
if (wantVisible != _chromeVisible) {
|
||||||
setState(() {
|
setState(() => _chromeVisible = wantVisible);
|
||||||
_barVisible = wantBar;
|
|
||||||
_fobVisible = wantFob;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,14 +70,14 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
AnimatedSlide(
|
AnimatedSlide(
|
||||||
offset: _barVisible ? Offset.zero : const Offset(0, -1),
|
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: _barVisible
|
child: _chromeVisible
|
||||||
? const AddressBar()
|
? const AddressBar()
|
||||||
: const SizedBox(width: double.infinity),
|
: const SizedBox(width: double.infinity),
|
||||||
),
|
),
|
||||||
|
|
@ -110,7 +105,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Fob(
|
child: Fob(
|
||||||
visible: _fobVisible,
|
visible: _chromeVisible,
|
||||||
label: strings.t('reader-fob-label'),
|
label: strings.t('reader-fob-label'),
|
||||||
onTap: () => showReaderSettingsSheet(context),
|
onTap: () => showReaderSettingsSheet(context),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
190
app/test/app_widget_test.dart
Normal file
190
app/test/app_widget_test.dart
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import 'dart:io' show File;
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.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/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';
|
||||||
|
|
||||||
|
const pageSource = '''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Signal Bars</title>
|
||||||
|
<meta name="mews-profile" content="0.1" />
|
||||||
|
</head>
|
||||||
|
<body class="mews-green">
|
||||||
|
<h1>Signal Bars</h1>
|
||||||
|
<p>Notes on old phones.</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="2026-10-08-retro.html">2026-10-08 Collecting retro phones</a></li>
|
||||||
|
<li><a href="2026-09-21-first.html">2026-09-21 First post</a></li>
|
||||||
|
</ul>
|
||||||
|
<form action="/sign" method="post">
|
||||||
|
<label for="name">Your name</label>
|
||||||
|
<input type="text" id="name" name="name" />
|
||||||
|
<input type="submit" value="Sign guestbook" />
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MewsDatabase db;
|
||||||
|
late AppDependencies deps;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = MewsDatabase.test(NativeDatabase.memory());
|
||||||
|
deps = AppDependencies(database: db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpApp(WidgetTester tester) async {
|
||||||
|
final strings = Strings();
|
||||||
|
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
|
||||||
|
await tester.pumpWidget(MewsApp(deps: deps, strings: strings));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('renders a page through the full app', (tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
final page = parseSource(pageSource, 'https://example.com/index.html');
|
||||||
|
deps.browser.currentPage.value = page;
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Signal Bars'), findsWidgets);
|
||||||
|
expect(find.textContaining('Collecting retro phones'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Sign guestbook'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the broken-page error and its text view', (tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
deps.browser.currentPage.value = null;
|
||||||
|
deps.browser.error.value = const BrokenPage('<p>Half a page & more');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('markup error'), findsOneWidget);
|
||||||
|
await tester.tap(find.textContaining('Show page text'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Half a page & more'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the load-failed error page', (tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
deps.browser.error.value = const LoadFailed();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('could not be loaded'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Try again'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('settings sheet changes presentation and toggles library',
|
||||||
|
(tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
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.
|
||||||
|
await tester.tap(find.text('Atkinson Hyperlegible'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dark'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
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.
|
||||||
|
final sheet = find.byType(Scrollable).last;
|
||||||
|
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'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue);
|
||||||
|
|
||||||
|
// Settings survive a reload of the controller.
|
||||||
|
final reloaded = ReaderSettingsController(db);
|
||||||
|
await reloaded.load();
|
||||||
|
expect(reloaded.themeMode.value, 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the FOB hides on scroll down and returns on scroll up',
|
||||||
|
(tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
// A page long enough to scroll.
|
||||||
|
final tall = pageSource.replaceFirst(
|
||||||
|
'</body>',
|
||||||
|
'<p>Padding paragraph to make the page scroll.</p>' * 80 + '</body>');
|
||||||
|
final page = parseSource(tall, 'https://example.com/index.html');
|
||||||
|
deps.browser.currentPage.value = page;
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
Fob fob() => tester.widget<Fob>(find.byType(Fob));
|
||||||
|
expect(fob().visible, isTrue);
|
||||||
|
|
||||||
|
// Scrolling down hides the address bar and the FOB.
|
||||||
|
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.
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, 300));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(fob().visible, isTrue);
|
||||||
|
expect(find.byType(AddressBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports new posts once per page', (tester) async {
|
||||||
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
final page = parseSource(pageSource, 'https://example.com/index.html');
|
||||||
|
deps.library.subscribedUrls.value = {'https://example.com/index.html'};
|
||||||
|
deps.library.newEntries.value = {
|
||||||
|
'https://example.com/index.html': [
|
||||||
|
MewsEntry(
|
||||||
|
url: 'https://example.com/2026-10-08-retro.html',
|
||||||
|
date: DateTime(2026, 10, 8),
|
||||||
|
title: 'Collecting retro phones'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
deps.browser.currentPage.value = page;
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('1 new post'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
MewsPage parseSource(String source, String url) =>
|
||||||
|
parseMewsPage(source: source, url: url);
|
||||||
|
|
@ -164,4 +164,39 @@ void main() {
|
||||||
final text = mewsPlainText(broken);
|
final text = mewsPlainText(broken);
|
||||||
expect(text, contains('Visible & intact.'));
|
expect(text, contains('Visible & intact.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps bare inline content in list items and quotes', () {
|
||||||
|
final source = '''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head><title>Bare</title><meta name="mews-profile" content="0.1" /></head>
|
||||||
|
<body>
|
||||||
|
<ul>
|
||||||
|
<li><a href="2026-10-08-post.html">2026-10-08 A dated link</a></li>
|
||||||
|
<li>Plain item</li>
|
||||||
|
<li>Text then a list:<ul><li>Nested</li></ul></li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>Quoted words <em>verbatim</em>.</blockquote>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
final page = parseMewsPage(source: source, url: 'https://example.com/bare.html');
|
||||||
|
final list = page.body[0] as ListBlock;
|
||||||
|
final first = (list.items[0].blocks.single as ParagraphBlock).inline;
|
||||||
|
final link = first.single as MewsLink;
|
||||||
|
expect(link.href, 'https://example.com/2026-10-08-post.html');
|
||||||
|
expect((link.children.single as MewsText).text, '2026-10-08 A dated link');
|
||||||
|
|
||||||
|
expect((list.items[1].blocks.single as ParagraphBlock).inline.single,
|
||||||
|
isA<MewsText>());
|
||||||
|
// Text before a nested list stays with the item; the list becomes a block.
|
||||||
|
expect(list.items[2].blocks, hasLength(2));
|
||||||
|
expect(list.items[2].blocks[0], isA<ParagraphBlock>());
|
||||||
|
expect(list.items[2].blocks[1], isA<ListBlock>());
|
||||||
|
|
||||||
|
final quote = page.body[1] as QuoteBlock;
|
||||||
|
final quoteText = (quote.children.single as ParagraphBlock).inline;
|
||||||
|
expect(quoteText, hasLength(3));
|
||||||
|
expect((quoteText[0] as MewsText).text, 'Quoted words ');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,16 @@ Future<MewsApp> _app() async {
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('starts with the address bar and no FOB', (tester) async {
|
testWidgets('starts with the address bar and the FOB on screen', (tester) async {
|
||||||
await tester.pumpWidget(await _app());
|
await tester.pumpWidget(await _app());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
expect(find.text('Enter a web address'), findsOneWidget);
|
expect(find.text('Enter a web address'), findsOneWidget);
|
||||||
// The FOB only appears once the reader scrolls a page; without a page it
|
// The FOB is on screen from the beginning and hides only while the
|
||||||
// stays invisible and untouchable.
|
// reader scrolls down.
|
||||||
final fob = tester.widget<Fob>(find.byType(Fob));
|
final fob = tester.widget<Fob>(find.byType(Fob));
|
||||||
expect(fob.visible, isFalse);
|
expect(fob.visible, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders a page and follows links', (tester) async {
|
testWidgets('renders a page and follows links', (tester) async {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue