feat: show real neighboring pages during back and forward swipes

This commit is contained in:
randogoth 2026-10-11 11:05:03 +03:00
parent 4e07c9a305
commit c104c2026b
7 changed files with 283 additions and 71 deletions

View file

@ -31,6 +31,15 @@ class SubmitForm extends FetchCommand {
final Map<String, String> fields; final Map<String, String> 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 { sealed class FetchResult {
const FetchResult(); const FetchResult();
} }
@ -94,6 +103,7 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
await _fetchPage(url, etag, lastModified, cachedSource), await _fetchPage(url, etag, lastModified, cachedSource),
SubmitForm(:final url, :final method, :final fields) => SubmitForm(:final url, :final method, :final fields) =>
await _submitForm(url, method, fields), await _submitForm(url, method, fields),
ParseSource(:final url, :final source) => _parse(source, url),
}; };
} }

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:actors/actors.dart'; import 'package:actors/actors.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:signals/signals.dart'; import 'package:signals/signals.dart';
@ -47,9 +45,10 @@ class BrowserController {
/// Pages opened this session, so links can show the visited color. /// Pages opened this session, so links can show the visited color.
final visitedUrls = signal(<String>{}); final visitedUrls = signal(<String>{});
/// Titles of the stack's neighboring pages, shown while swiping. /// The parsed pages next to the current one in the stack, ready to show
final backTitle = signal<String?>(null); /// while swiping between them.
final forwardTitle = signal<String?>(null); final backPage = signal<MewsPage?>(null);
final forwardPage = signal<MewsPage?>(null);
bool get canGoBack => _stackIndex > 0; bool get canGoBack => _stackIndex > 0;
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
@ -69,24 +68,36 @@ class BrowserController {
_stack.removeRange(_stackIndex + 1, _stack.length); _stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(url); _stack.add(url);
_stackIndex++; _stackIndex++;
_updatePeeks(); _updateNeighbors();
await _loadCurrent(); await _loadCurrent();
} }
Future<void> back() async { Future<void> back() async {
if (!canGoBack) return; if (!canGoBack) return;
_stackIndex--; _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(); await _loadCurrent();
} }
Future<void> forward() async { Future<void> forward() async {
if (!canGoForward) return; if (!canGoForward) return;
_stackIndex++; _stackIndex++;
_updatePeeks(); _showParsedNeighbor(forwardPage.value);
_updateNeighbors();
await _loadCurrent(); await _loadCurrent();
} }
void _showParsedNeighbor(MewsPage? page) {
if (page == null) return;
currentPage.value = page;
error.value = null;
visitedUrls.value = {...visitedUrls.value, page.url};
}
Future<void> reload() => _loadCurrent(); Future<void> reload() => _loadCurrent();
Future<void> submitForm(String action, String method, Map<String, String> fields) async { Future<void> submitForm(String action, String method, Map<String, String> fields) async {
@ -99,7 +110,7 @@ class BrowserController {
_stack.removeRange(_stackIndex + 1, _stack.length); _stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(page.url); _stack.add(page.url);
_stackIndex++; _stackIndex++;
_updatePeeks(); _updateNeighbors();
_show(page); _show(page);
case _: case _:
error.value = const LoadFailed(); error.value = const LoadFailed();
@ -112,21 +123,34 @@ class BrowserController {
if (url == null) return; if (url == null) return;
_stack.add(url); _stack.add(url);
_stackIndex = 0; _stackIndex = 0;
_updatePeeks(); _updateNeighbors();
await _loadCurrent(); await _loadCurrent();
} }
/// Refreshes the neighboring-page titles from the cache; a page without a /// Parses the stack's neighboring pages in the fetch actor so swipes can
/// cached title falls back to its URL. /// show the real content. Results arriving after another navigation are
void _updatePeeks() { /// dropped, so rapid navigation never shows a stale neighbor.
Future<String?> peek(int index) async { void _updateNeighbors() {
if (index < 0 || index >= _stack.length) return null; _loadNeighbor(_stackIndex - 1, backPage);
final url = _stack[index]; _loadNeighbor(_stackIndex + 1, forwardPage);
return (await _db.cachedPage(url))?.title ?? url; }
}
unawaited(peek(_stackIndex - 1).then((t) => backTitle.value = t)); Future<void> _loadNeighbor(int index, Signal<MewsPage?> target) async {
unawaited(peek(_stackIndex + 1).then((t) => forwardTitle.value = t)); 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<void> _loadCurrent() async { Future<void> _loadCurrent() async {

View file

@ -12,10 +12,10 @@ import 'reader/reader_screen.dart';
/// 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.
class AppDependencies { class AppDependencies {
AppDependencies({MewsDatabase? database}) AppDependencies({MewsDatabase? database, Actor<FetchCommand, FetchResult>? fetch})
: db = database ?? MewsDatabase(), : db = database ?? MewsDatabase(),
fetch = Actor.create(FetchHandler.new) { fetch = fetch ?? Actor.create(FetchHandler.new) {
browser = BrowserController(db: db, fetch: fetch); browser = BrowserController(db: db, fetch: this.fetch);
settings = ReaderSettingsController(db); settings = ReaderSettingsController(db);
library = LibraryController(db); library = LibraryController(db);
} }

View file

@ -100,8 +100,10 @@ class _ReaderScreenState extends State<ReaderScreen> {
return SwipeNavigator( return SwipeNavigator(
canGoBack: browser.canGoBack, canGoBack: browser.canGoBack,
canGoForward: browser.canGoForward, canGoForward: browser.canGoForward,
backTitle: browser.backTitle.value, backView: _neighborView(browser.backPage.value),
forwardTitle: browser.forwardTitle.value, forwardView: _neighborView(browser.forwardPage.value),
backTitle: browser.backPage.value?.title,
forwardTitle: browser.forwardPage.value?.title,
background: peekBackground, background: peekBackground,
onBack: browser.back, onBack: browser.back,
onForward: browser.forward, onForward: browser.forward,
@ -179,6 +181,31 @@ class _ReaderScreenState extends State<ReaderScreen> {
}); });
} }
/// 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) { Widget _emptyPage(BuildContext context) {
// No page yet: the address bar is the whole UI. // No page yet: the address bar is the whole UI.
return const SizedBox.expand(); return const SizedBox.expand();

View file

@ -4,27 +4,37 @@ import 'package:flutter/material.dart';
/// Wraps the current page and turns the back/forward swipes into visible page /// Wraps the current page and turns the back/forward swipes into visible page
/// transitions: dragging slides the current page away and reveals the /// transitions: dragging slides the current page away and reveals the
/// neighboring page peeking in from the side (its title, when known). /// neighboring page itself ([backView]/[forwardView]) sliding in from the
/// Releasing past the threshold or with a flick commits the navigation; /// side, falling back to a title panel while the neighbor is still being
/// otherwise everything springs back. Directions without a page resist the /// parsed. Releasing past the threshold or with a flick commits the
/// drag with a rubber band. /// navigation; otherwise everything springs back. Directions without a page
/// resist the drag with a rubber band.
class SwipeNavigator extends StatefulWidget { class SwipeNavigator extends StatefulWidget {
const SwipeNavigator({ const SwipeNavigator({
required this.canGoBack, required this.canGoBack,
required this.canGoForward, required this.canGoForward,
required this.backTitle,
required this.forwardTitle,
required this.background, required this.background,
required this.onBack, required this.onBack,
required this.onForward, required this.onForward,
required this.child, required this.child,
this.backView,
this.forwardView,
this.backTitle,
this.forwardTitle,
super.key, super.key,
}); });
final bool canGoBack; final bool canGoBack;
final bool canGoForward; 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? backTitle;
final String? forwardTitle; final String? forwardTitle;
final Color background; final Color background;
final VoidCallback onBack; final VoidCallback onBack;
final VoidCallback onForward; final VoidCallback onForward;
@ -135,14 +145,21 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
// rubber-band the current page over the bare background. // rubber-band the current page over the bare background.
final showsBack = dx > 0 && widget.canGoBack; final showsBack = dx > 0 && widget.canGoBack;
final showsForward = dx < 0 && widget.canGoForward; final showsForward = dx < 0 && widget.canGoForward;
final peek = showsBack || showsForward final Widget? peek = showsBack || showsForward
? _Peek( ? Transform.translate(
fromLeft: showsBack, offset: Offset(showsBack ? dx - _width : _width + dx, 0),
offset: showsBack ? dx - _width : _width + dx, child: IgnorePointer(
background: widget.background, // The neighbor is a look ahead, not an interactive page;
title: showsBack ? widget.backTitle : widget.forwardTitle, // 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( return GestureDetector(
behavior: HitTestBehavior.deferToChild, behavior: HitTestBehavior.deferToChild,
@ -151,7 +168,7 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
onHorizontalDragEnd: _onDragEnd, onHorizontalDragEnd: _onDragEnd,
child: Stack( child: Stack(
children: [ children: [
Positioned.fill(child: peek), if (peek != null) Positioned.fill(child: peek),
Transform.translate( Transform.translate(
offset: Offset(dx, 0), offset: Offset(dx, 0),
child: widget.child, child: widget.child,
@ -164,52 +181,47 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
} }
} }
/// 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 { class _Peek extends StatelessWidget {
const _Peek({ const _Peek({
required this.fromLeft, required this.fromLeft,
required this.offset,
required this.background, required this.background,
required this.title, required this.title,
}); });
final bool fromLeft; final bool fromLeft;
final double offset;
final Color background; final Color background;
final String? title; final String? title;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Transform.translate( return ColoredBox(
offset: Offset(offset, 0), color: background,
child: ColoredBox( child: Padding(
color: background, padding: const EdgeInsets.symmetric(horizontal: 24),
child: Padding( child: Align(
padding: const EdgeInsets.symmetric(horizontal: 24), alignment: fromLeft ? Alignment.centerLeft : Alignment.centerRight,
child: Align( child: Column(
alignment: fromLeft ? Alignment.centerLeft : Alignment.centerRight, mainAxisSize: MainAxisSize.min,
child: Column( crossAxisAlignment: fromLeft
mainAxisSize: MainAxisSize.min, ? CrossAxisAlignment.start
crossAxisAlignment: fromLeft : CrossAxisAlignment.end,
? CrossAxisAlignment.start children: [
: CrossAxisAlignment.end, Icon(
children: [ fromLeft ? Icons.west : Icons.east,
Icon( color: Theme.of(context).colorScheme.onSurface,
fromLeft ? Icons.west : Icons.east, ),
color: Theme.of(context).colorScheme.onSurface, if (title != null)
), ConstrainedBox(
if (title != null) constraints: const BoxConstraints(maxWidth: 200),
ConstrainedBox( child: Text(
constraints: const BoxConstraints(maxWidth: 200), title!,
child: Text( maxLines: 2,
title!, overflow: TextOverflow.ellipsis,
maxLines: 2, style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
), ),
], ),
), ],
), ),
), ),
), ),

View file

@ -1,9 +1,11 @@
import 'dart:io' show File; import 'dart:io' show File;
import 'package:actors/actors.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/actors/fetch_actor.dart';
import 'package:mews/application/controllers/browser_controller.dart' import 'package:mews/application/controllers/browser_controller.dart'
show BrokenPage, LoadFailed; show BrokenPage, LoadFailed;
import 'package:mews/application/controllers/reader_settings_controller.dart' 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/fob.dart';
import 'package:mews/presentation/reader/reader_screen.dart'; import 'package:mews/presentation/reader/reader_screen.dart';
import 'package:mews/presentation/reader/settings_sheet.dart'; import 'package:mews/presentation/reader/settings_sheet.dart';
import 'package:mews/presentation/reader/swipe_navigator.dart';
const pageSource = ''' const pageSource = '''
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@ -41,6 +44,50 @@ const pageSource = '''
</html> </html>
'''; ''';
/// 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<FetchCommand, FetchResult> {
_FakeFetch(this.sources);
final Map<String, String> sources;
@override
Future<FetchResult> 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<void> close() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
String _page(String title, String body) => '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>$title</title><meta name="mews-profile" content="0.1" /></head>
<body>$body</body>
</html>
''';
void main() { void main() {
late MewsDatabase db; late MewsDatabase db;
late AppDependencies deps; late AppDependencies deps;
@ -166,6 +213,46 @@ void main() {
expect(find.byType(AddressBar), findsOneWidget); 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', '<h1>Page A</h1><p>The first pages content.</p>'),
'https://example.com/b.html':
_page('Page B', '<h1>Page B</h1><p>The second pages content.</p>'),
};
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 { testWidgets('reports new posts once per page', (tester) async {
await pumpApp(tester); await pumpApp(tester);
@ -188,3 +275,16 @@ void main() {
MewsPage parseSource(String source, String url) => MewsPage parseSource(String source, String url) =>
parseMewsPage(source: source, url: url); parseMewsPage(source: source, url: url);
/// Pumps in short bursts until [condition] holds; fails the test on timeout.
Future<void> _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();
}

View file

@ -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 = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Parsed</title><meta name="mews-profile" content="0.1" /></head>
<body><p>Some content.</p></body>
</html>
''';
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<FetchPageOk>());
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: '<p>nope'));
expect(result, isA<FetchParseError>());
expect((result as FetchParseError).source, '<p>nope');
});
}