diff --git a/app/lib/mews/parser.dart b/app/lib/mews/parser.dart index d2692bd..6d288b7 100644 --- a/app/lib/mews/parser.dart +++ b/app/lib/mews/parser.dart @@ -156,9 +156,21 @@ void _appendBlocks(XmlElement element, String pageUrl, List out) { out.addAll(embedded); case 'blockquote': final children = []; - for (final child in element.children.whereType()) { - _appendBlocks(child, pageUrl, children); + final embedded = []; + // Bare inline content (quotes are often written as plain text) becomes + // a paragraph, like in list items. + final inline = []; + 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)); case 'pre': out.add(PreBlock(element.innerText)); @@ -193,13 +205,33 @@ void _appendBlocks(XmlElement element, String pageUrl, List 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) { final items = []; for (final li in element.findElements('li')) { final blocks = []; - for (final child in li.children.whereType()) { - _appendBlocks(child, pageUrl, blocks); + final embedded = []; + // 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 = []; + 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)); } return ListBlock( diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index f9ff387..dd83578 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -8,7 +8,6 @@ import '../application/controllers/reader_settings_controller.dart'; import '../database/database.dart'; import '../l10n/strings.dart'; import 'reader/reader_screen.dart'; - /// 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 /// actor is the only path to the network. @@ -63,7 +62,10 @@ class MewsApp extends StatelessWidget { return AppScope( deps: deps, strings: strings, - child: const _ThemedApp(), + child: StringsScope( + strings: strings, + child: const _ThemedApp(), + ), ); } } diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 6599438..e62a828 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -25,8 +25,10 @@ class ReaderScreen extends StatefulWidget { class _ReaderScreenState extends State { 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; final _reportedNewPosts = {}; final _checkedSubscriptions = {}; @@ -48,20 +50,13 @@ class _ReaderScreenState extends State { if (!_scroll.hasClients) return; final position = _scroll.position; - // The bar hides when the reader scrolls down and comes back on any - // upward scroll; it is always visible at the top of the page. - final wantBar = position.pixels <= 0 || + // The bar and FOB hide when the reader scrolls down and come back on any + // upward scroll; both are always visible at the top of the page. + final wantVisible = position.pixels <= 0 || 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) { - setState(() { - _barVisible = wantBar; - _fobVisible = wantFob; - }); + if (wantVisible != _chromeVisible) { + setState(() => _chromeVisible = wantVisible); } } @@ -75,14 +70,14 @@ class _ReaderScreenState extends State { body: Column( children: [ AnimatedSlide( - offset: _barVisible ? Offset.zero : const Offset(0, -1), + offset: _chromeVisible ? Offset.zero : const Offset(0, -1), duration: const Duration(milliseconds: 200), curve: Curves.easeOut, child: AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, alignment: Alignment.topCenter, - child: _barVisible + child: _chromeVisible ? const AddressBar() : const SizedBox(width: double.infinity), ), @@ -110,7 +105,7 @@ class _ReaderScreenState extends State { right: 0, bottom: 0, child: Fob( - visible: _fobVisible, + visible: _chromeVisible, label: strings.t('reader-fob-label'), onTap: () => showReaderSettingsSheet(context), ), diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart new file mode 100644 index 0000000..fa149fb --- /dev/null +++ b/app/test/app_widget_test.dart @@ -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 = ''' + + + + Signal Bars + + + +

Signal Bars

+

Notes on old phones.

+ +
+ + + +
+ + +'''; + +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 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('

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( + '', + '

Padding paragraph to make the page scroll.

' * 80 + ''); + final page = parseSource(tall, 'https://example.com/index.html'); + deps.browser.currentPage.value = page; + await tester.pumpAndSettle(); + + Fob fob() => tester.widget(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); diff --git a/app/test/parser_test.dart b/app/test/parser_test.dart index c4a32e4..4e6039c 100644 --- a/app/test/parser_test.dart +++ b/app/test/parser_test.dart @@ -164,4 +164,39 @@ void main() { final text = mewsPlainText(broken); expect(text, contains('Visible & intact.')); }); + + test('keeps bare inline content in list items and quotes', () { + final source = ''' + + + Bare + + +
Quoted words verbatim.
+ + +'''; + 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()); + // 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()); + expect(list.items[2].blocks[1], isA()); + + 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 '); + }); } diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart index 23f52f2..71fea69 100644 --- a/app/test/reader_widget_test.dart +++ b/app/test/reader_widget_test.dart @@ -20,16 +20,16 @@ Future _app() async { } 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.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); expect(find.text('Enter a web address'), findsOneWidget); - // The FOB only appears once the reader scrolls a page; without a page it - // stays invisible and untouchable. + // The FOB is on screen from the beginning and hides only while the + // reader scrolls down. final fob = tester.widget(find.byType(Fob)); - expect(fob.visible, isFalse); + expect(fob.visible, isTrue); }); testWidgets('renders a page and follows links', (tester) async {