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;
}
/// 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<FetchCommand, FetchResult> {
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),
};
}

View file

@ -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(<String>{});
/// Titles of the stack's neighboring pages, shown while swiping.
final backTitle = signal<String?>(null);
final forwardTitle = signal<String?>(null);
/// The parsed pages next to the current one in the stack, ready to show
/// while swiping between them.
final backPage = signal<MewsPage?>(null);
final forwardPage = signal<MewsPage?>(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<void> 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<void> 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<void> reload() => _loadCurrent();
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.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<String?> 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<void> _loadNeighbor(int index, Signal<MewsPage?> 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<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
/// actor is the only path to the network.
class AppDependencies {
AppDependencies({MewsDatabase? database})
AppDependencies({MewsDatabase? database, Actor<FetchCommand, FetchResult>? 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);
}

View file

@ -100,8 +100,10 @@ class _ReaderScreenState extends State<ReaderScreen> {
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<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) {
// No page yet: the address bar is the whole UI.
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
/// 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<SwipeNavigator>
// 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<SwipeNavigator>
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<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 {
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,
),
],
),
),
],
),
),
),

View file

@ -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 = '''
<?xml version="1.0" encoding="UTF-8"?>
@ -41,6 +44,50 @@ const pageSource = '''
</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() {
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', '<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 {
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<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');
});
}