feat: show real neighboring pages during back and forward swipes
This commit is contained in:
parent
4e07c9a305
commit
c104c2026b
7 changed files with 283 additions and 71 deletions
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
39
app/test/fetch_actor_test.dart
Normal file
39
app/test/fetch_actor_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue