From c104c2026b972680fc2c71e422ef75a1c132cc27 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 11:05:03 +0300 Subject: [PATCH] feat: show real neighboring pages during back and forward swipes --- app/lib/application/actors/fetch_actor.dart | 10 ++ .../controllers/browser_controller.dart | 64 +++++++---- app/lib/presentation/app.dart | 6 +- .../presentation/reader/reader_screen.dart | 31 +++++- .../presentation/reader/swipe_navigator.dart | 104 ++++++++++-------- app/test/app_widget_test.dart | 100 +++++++++++++++++ app/test/fetch_actor_test.dart | 39 +++++++ 7 files changed, 283 insertions(+), 71 deletions(-) create mode 100644 app/test/fetch_actor_test.dart diff --git a/app/lib/application/actors/fetch_actor.dart b/app/lib/application/actors/fetch_actor.dart index c1895de..109093f 100644 --- a/app/lib/application/actors/fetch_actor.dart +++ b/app/lib/application/actors/fetch_actor.dart @@ -31,6 +31,15 @@ class SubmitForm extends FetchCommand { final Map fields; } +/// Parses a page's source without touching the network; used to prepare the +/// neighboring pages that swipes reveal. +class ParseSource extends FetchCommand { + const ParseSource({required this.url, required this.source}); + + final String url; + final String source; +} + sealed class FetchResult { const FetchResult(); } @@ -94,6 +103,7 @@ class FetchHandler with Handler { await _fetchPage(url, etag, lastModified, cachedSource), SubmitForm(:final url, :final method, :final fields) => await _submitForm(url, method, fields), + ParseSource(:final url, :final source) => _parse(source, url), }; } diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index 8730eba..c8f154a 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:actors/actors.dart'; import 'package:drift/drift.dart' show Value; import 'package:signals/signals.dart'; @@ -47,9 +45,10 @@ class BrowserController { /// Pages opened this session, so links can show the visited color. final visitedUrls = signal({}); - /// Titles of the stack's neighboring pages, shown while swiping. - final backTitle = signal(null); - final forwardTitle = signal(null); + /// The parsed pages next to the current one in the stack, ready to show + /// while swiping between them. + final backPage = signal(null); + final forwardPage = signal(null); bool get canGoBack => _stackIndex > 0; bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; @@ -69,24 +68,36 @@ class BrowserController { _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(url); _stackIndex++; - _updatePeeks(); + _updateNeighbors(); await _loadCurrent(); } Future back() async { if (!canGoBack) return; _stackIndex--; - _updatePeeks(); + // The neighbor was parsed for the swipe already; show it right away so + // the transition lands on content instead of a blank page while + // _loadCurrent revalidates it against the server. + _showParsedNeighbor(backPage.value); + _updateNeighbors(); await _loadCurrent(); } Future forward() async { if (!canGoForward) return; _stackIndex++; - _updatePeeks(); + _showParsedNeighbor(forwardPage.value); + _updateNeighbors(); await _loadCurrent(); } + void _showParsedNeighbor(MewsPage? page) { + if (page == null) return; + currentPage.value = page; + error.value = null; + visitedUrls.value = {...visitedUrls.value, page.url}; + } + Future reload() => _loadCurrent(); Future submitForm(String action, String method, Map fields) async { @@ -99,7 +110,7 @@ class BrowserController { _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(page.url); _stackIndex++; - _updatePeeks(); + _updateNeighbors(); _show(page); case _: error.value = const LoadFailed(); @@ -112,21 +123,34 @@ class BrowserController { if (url == null) return; _stack.add(url); _stackIndex = 0; - _updatePeeks(); + _updateNeighbors(); await _loadCurrent(); } - /// Refreshes the neighboring-page titles from the cache; a page without a - /// cached title falls back to its URL. - void _updatePeeks() { - Future peek(int index) async { - if (index < 0 || index >= _stack.length) return null; - final url = _stack[index]; - return (await _db.cachedPage(url))?.title ?? url; - } + /// Parses the stack's neighboring pages in the fetch actor so swipes can + /// show the real content. Results arriving after another navigation are + /// dropped, so rapid navigation never shows a stale neighbor. + void _updateNeighbors() { + _loadNeighbor(_stackIndex - 1, backPage); + _loadNeighbor(_stackIndex + 1, forwardPage); + } - unawaited(peek(_stackIndex - 1).then((t) => backTitle.value = t)); - unawaited(peek(_stackIndex + 1).then((t) => forwardTitle.value = t)); + Future _loadNeighbor(int index, Signal target) async { + final expectedIndex = _stackIndex; + if (index < 0 || index >= _stack.length) { + target.value = null; + return; + } + final url = _stack[index]; + final cached = await _db.cachedPage(url); + if (_stackIndex != expectedIndex) return; + if (cached == null) { + target.value = null; + return; + } + final result = await _fetch.send(ParseSource(url: url, source: cached.source)); + if (_stackIndex != expectedIndex) return; + target.value = result is FetchPageOk ? result.page : null; } Future _loadCurrent() async { diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index dd83578..f7acb8d 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -12,10 +12,10 @@ import 'reader/reader_screen.dart'; /// Everything is local-first: the database is created up front and the fetch /// actor is the only path to the network. class AppDependencies { - AppDependencies({MewsDatabase? database}) + AppDependencies({MewsDatabase? database, Actor? fetch}) : db = database ?? MewsDatabase(), - fetch = Actor.create(FetchHandler.new) { - browser = BrowserController(db: db, fetch: fetch); + fetch = fetch ?? Actor.create(FetchHandler.new) { + browser = BrowserController(db: db, fetch: this.fetch); settings = ReaderSettingsController(db); library = LibraryController(db); } diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 44a3115..dfb2a59 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -100,8 +100,10 @@ class _ReaderScreenState extends State { return SwipeNavigator( canGoBack: browser.canGoBack, canGoForward: browser.canGoForward, - backTitle: browser.backTitle.value, - forwardTitle: browser.forwardTitle.value, + backView: _neighborView(browser.backPage.value), + forwardView: _neighborView(browser.forwardPage.value), + backTitle: browser.backPage.value?.title, + forwardTitle: browser.forwardPage.value?.title, background: peekBackground, onBack: browser.back, onForward: browser.forward, @@ -179,6 +181,31 @@ class _ReaderScreenState extends State { }); } + /// Renders a neighboring page for the swipe peek, non-interactive: it is a + /// look ahead, and committing the navigation brings the real thing. + Widget? _neighborView(MewsPage? page) { + if (page == null) return null; + final deps = AppScope.of(context).deps; + final settings = deps.settings; + final palette = paletteFor( + pageVariant: page.variant, + readerVariant: settings.variant.value, + dark: Theme.of(context).brightness == Brightness.dark, + ); + return ColoredBox( + color: palette.background, + child: MewsPageView( + page: page, + palette: palette, + fontFamily: settings.font.value, + fontScale: settings.fontScale.value, + visitedUrls: deps.browser.visitedUrls.value, + onLinkTap: (_) {}, + onFormSubmit: (_, _) {}, + ), + ); + } + Widget _emptyPage(BuildContext context) { // No page yet: the address bar is the whole UI. return const SizedBox.expand(); diff --git a/app/lib/presentation/reader/swipe_navigator.dart b/app/lib/presentation/reader/swipe_navigator.dart index 7a69e6b..0caa5a7 100644 --- a/app/lib/presentation/reader/swipe_navigator.dart +++ b/app/lib/presentation/reader/swipe_navigator.dart @@ -4,27 +4,37 @@ import 'package:flutter/material.dart'; /// Wraps the current page and turns the back/forward swipes into visible page /// transitions: dragging slides the current page away and reveals the -/// neighboring page peeking in from the side (its title, when known). -/// Releasing past the threshold or with a flick commits the navigation; -/// otherwise everything springs back. Directions without a page resist the -/// drag with a rubber band. +/// neighboring page itself ([backView]/[forwardView]) sliding in from the +/// side, falling back to a title panel while the neighbor is still being +/// parsed. Releasing past the threshold or with a flick commits the +/// navigation; otherwise everything springs back. Directions without a page +/// resist the drag with a rubber band. class SwipeNavigator extends StatefulWidget { const SwipeNavigator({ required this.canGoBack, required this.canGoForward, - required this.backTitle, - required this.forwardTitle, required this.background, required this.onBack, required this.onForward, required this.child, + this.backView, + this.forwardView, + this.backTitle, + this.forwardTitle, super.key, }); final bool canGoBack; final bool canGoForward; + + /// Pre-rendered neighboring pages, shown while swiping. + final Widget? backView; + final Widget? forwardView; + + /// Fallback titles for the neighbor panels while the pages are parsed. final String? backTitle; final String? forwardTitle; + final Color background; final VoidCallback onBack; final VoidCallback onForward; @@ -135,14 +145,21 @@ class _SwipeNavigatorState extends State // rubber-band the current page over the bare background. final showsBack = dx > 0 && widget.canGoBack; final showsForward = dx < 0 && widget.canGoForward; - final peek = showsBack || showsForward - ? _Peek( - fromLeft: showsBack, - offset: showsBack ? dx - _width : _width + dx, - background: widget.background, - title: showsBack ? widget.backTitle : widget.forwardTitle, + final Widget? peek = showsBack || showsForward + ? Transform.translate( + offset: Offset(showsBack ? dx - _width : _width + dx, 0), + child: IgnorePointer( + // The neighbor is a look ahead, not an interactive page; + // committing the navigation brings the real one. + child: (showsBack ? widget.backView : widget.forwardView) ?? + _Peek( + fromLeft: showsBack, + background: widget.background, + title: showsBack ? widget.backTitle : widget.forwardTitle, + ), + ), ) - : const SizedBox.shrink(); + : null; return GestureDetector( behavior: HitTestBehavior.deferToChild, @@ -151,7 +168,7 @@ class _SwipeNavigatorState extends State onHorizontalDragEnd: _onDragEnd, child: Stack( children: [ - Positioned.fill(child: peek), + if (peek != null) Positioned.fill(child: peek), Transform.translate( offset: Offset(dx, 0), child: widget.child, @@ -164,52 +181,47 @@ class _SwipeNavigatorState extends State } } -/// The neighboring page sliding in under the current one. +/// The neighboring page's fallback panel while it is still being parsed. class _Peek extends StatelessWidget { const _Peek({ required this.fromLeft, - required this.offset, required this.background, required this.title, }); final bool fromLeft; - final double offset; final Color background; final String? title; @override Widget build(BuildContext context) { - return Transform.translate( - offset: Offset(offset, 0), - child: ColoredBox( - color: background, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Align( - alignment: fromLeft ? Alignment.centerLeft : Alignment.centerRight, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: fromLeft - ? CrossAxisAlignment.start - : CrossAxisAlignment.end, - children: [ - Icon( - fromLeft ? Icons.west : Icons.east, - color: Theme.of(context).colorScheme.onSurface, - ), - if (title != null) - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), - child: Text( - title!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), + return ColoredBox( + color: background, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: fromLeft ? Alignment.centerLeft : Alignment.centerRight, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: fromLeft + ? CrossAxisAlignment.start + : CrossAxisAlignment.end, + children: [ + Icon( + fromLeft ? Icons.west : Icons.east, + color: Theme.of(context).colorScheme.onSurface, + ), + if (title != null) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + title!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, ), - ], - ), + ), + ], ), ), ), diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index fa149fb..97a294a 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -1,9 +1,11 @@ import 'dart:io' show File; +import 'package:actors/actors.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; 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' @@ -17,6 +19,7 @@ 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 = ''' @@ -41,6 +44,50 @@ const pageSource = ''' '''; +/// Serves pages from an in-memory map without network or isolates, so +/// navigation tests are deterministic. Cached pages parse back from their +/// cached source, like the real actor does for revalidation. +class _FakeFetch implements Actor { + _FakeFetch(this.sources); + + final Map sources; + + @override + Future send(FetchCommand message) async { + return switch (message) { + ParseSource(:final url, :final source) => _parse(url, source), + FetchPage(:final url, :final cachedSource) => cachedSource != null + ? _parse(url, cachedSource) + : sources[url] != null + ? _parse(url, sources[url]!) + : const FetchNetworkError(), + _ => const FetchNetworkError(), + }; + } + + FetchResult _parse(String url, String source) { + try { + return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source); + } on MewsParseException catch (e) { + return FetchParseError(e.message, source); + } + } + + @override + Future close() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +String _page(String title, String body) => ''' + + + $title + $body + +'''; + void main() { late MewsDatabase db; late AppDependencies deps; @@ -166,6 +213,46 @@ void main() { expect(find.byType(AddressBar), findsOneWidget); }); + testWidgets('swiping back shows the previous page during the drag', + (tester) async { + final sources = { + 'https://example.com/a.html': + _page('Page A', '

Page A

The first pages content.

'), + 'https://example.com/b.html': + _page('Page B', '

Page B

The second pages content.

'), + }; + final stubDeps = AppDependencies( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: _FakeFetch(sources), + ); + final strings = Strings(); + strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + await stubDeps.browser.open('https://example.com/a.html'); + await stubDeps.browser.open('https://example.com/b.html'); + // The swipe neighbor is parsed in the background. + await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null); + + final center = tester.getCenter(find.byType(SwipeNavigator)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(400, 0)); + await tester.pump(); + + // The real previous page is on screen mid-swipe, not a placeholder. + expect(find.text('Page A'), findsOneWidget); + expect(find.textContaining('The first pages content'), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); + // Settled on the page itself; the address bar follows. + expect(find.textContaining('The first pages content'), findsOneWidget); + expect(find.text('https://example.com/a.html'), findsOneWidget); + }); + testWidgets('reports new posts once per page', (tester) async { await pumpApp(tester); @@ -188,3 +275,16 @@ void main() { MewsPage parseSource(String source, String url) => parseMewsPage(source: source, url: url); + +/// Pumps in short bursts until [condition] holds; fails the test on timeout. +Future _pumpUntil(WidgetTester tester, bool Function() condition, + {Duration timeout = const Duration(seconds: 5)}) async { + final end = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(end)) { + fail('condition not met within timeout'); + } + await tester.pump(const Duration(milliseconds: 20)); + } + await tester.pumpAndSettle(); +} diff --git a/app/test/fetch_actor_test.dart b/app/test/fetch_actor_test.dart new file mode 100644 index 0000000..5da3e5f --- /dev/null +++ b/app/test/fetch_actor_test.dart @@ -0,0 +1,39 @@ +import 'package:actors/actors.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/application/actors/fetch_actor.dart'; + +const page = ''' + + + Parsed +

Some content.

+ +'''; + +void main() { + test('ParseSource parses without touching the network', () async { + final actor = Actor.create(FetchHandler.new); + addTearDown(actor.close); + + final result = await actor + .send(const ParseSource(url: 'https://example.com/parsed.html', source: page)); + + expect(result, isA()); + final ok = result as FetchPageOk; + expect(ok.page.title, 'Parsed'); + expect(ok.page.isMews, isTrue); + expect(ok.notModified, isFalse); + }); + + test('ParseSource reports broken markup with its source', () async { + final actor = Actor.create(FetchHandler.new); + addTearDown(actor.close); + + final result = await actor.send( + const ParseSource(url: 'https://example.com/broken.html', source: '

nope')); + + expect(result, isA()); + expect((result as FetchParseError).source, '

nope'); + }); +}