fix: optimized swiping and address bar toggle on tap

This commit is contained in:
randogoth 2026-10-12 09:52:28 +03:00
parent ac998b394d
commit 0e2027c9e2
5 changed files with 466 additions and 78 deletions

View file

@ -77,10 +77,10 @@ Use the `actors` package only where Dart Isolate isolation is genuinely useful:
The v1 client presents one reader screen:
- An address field pinned at the top; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, and on pointer hosts it stays on screen, since the keyboard reaches it whenever it is needed. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed.
- An address field pinned at the top, below the status bar inset on phones; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, since a page that fits the viewport leaves nothing to scroll for it. A short tap on the page shows a hidden bar and hides a shown one, unless the tap opened something on the page — a link, a form, a control — for those leave the bar where it was. On pointer hosts it stays on screen, since the keyboard reaches it whenever it is needed. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed.
- A web page that cannot be loaded offers to open in the reader's system web browser, since the web can be shown outside; a page over gemini or spartan has nowhere else to go, so its failures offer only trying again.
- On pointer hosts the keyboard drives the reader: Ctrl or Cmd+L focuses the address field with its text selected, Ctrl or Cmd+R reloads the page, and the walk goes back and forward with Alt+Left and Alt+Right, or on a Mac with Cmd+Left, Cmd+Right, Cmd+[ and Cmd+]. Touch devices navigate with gestures instead, and none of these shortcuts are offered there. The links on a page are reachable by keyboard as well: the page is one stop in the focus order, and once it holds the focus, Tab highlights its links in reading order and Enter opens the highlighted one; the page scrolls to keep the highlighted link in view, Shift+Tab walks back, Escape puts the highlight away, and a Tab past either end hands the focus back to the chrome.
- A swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well.
- A swipe right goes back and a swipe left goes forward through the history stack. On a pointer host the whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text, and the address bar's buttons work as well. On a touch device, where the column leaves no margin, the page surface itself accepts the swipe: a finger that crosses the slop sideways, before the long press that starts a text selection completes, is swiping, while a finger that held still that long keeps its drag for the selection.
- The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription.
- `mews:posts` is every post from every subscribed site in one list. The reader can order it by date, by site, or unread first, and limit it to unread posts; those choices ride in the address as a query string, so going back undoes one and a swipe preview shows the page the reader will land on. Unread posts are shown bold, and opening a post marks it read.
- A site that publishes through an Atom feed instead of dated links is read through the feed (spec 6.2). Dated links are the normative mechanism, so the feed is only asked for when the page itself listed nothing, and a feed on another site is refused. Posts are keyed by address, so a post listed both ways is stored once, under the title the page gave it.

View file

@ -2,6 +2,8 @@ import 'dart:io' show Process;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/gestures.dart'
show kLongPressTimeout, kTouchSlop, PointerDownEvent, PointerUpEvent;
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
@ -47,6 +49,16 @@ class _ReaderScreenState extends State<ReaderScreen> {
bool _showBrokenText = false;
final _checkedSubscriptions = <String>{};
// The finger behind a possible tap on the page, tracked outside the
// gesture arena because taps over selectable text lose it.
Offset? _tapDownPosition;
Duration _tapDownTime = Duration.zero;
/// Whether the tap in flight acted on the page: opened a link, submitted
/// a form or pressed a control in one of the chrome's own pages. Such a
/// tap leaves the bar where it was.
bool _tapActed = false;
/// Touch devices navigate with gestures; the others are pointer hosts
/// whose readers reach the chrome with the mouse and the keyboard.
static bool get _isTouch => switch (defaultTargetPlatform) {
@ -85,73 +97,135 @@ class _ReaderScreenState extends State<ReaderScreen> {
}
}
void _onPageTapDown(PointerDownEvent event) {
_tapDownPosition = event.position;
_tapDownTime = event.timeStamp;
_tapActed = false;
}
/// A short tap on the page shows a hidden address bar and hides a shown
/// one. A page that fits the viewport never scrolls, so a hidden bar has
/// nothing else to bring it back.
void _onPageTapUp(PointerUpEvent event) {
if (_tapDownPosition == null || !_isTouch) return;
final travel = (event.position - _tapDownPosition!).distance;
_tapDownPosition = null;
// A finger that travelled was scrolling or swiping; one that held that
// long began a selection. Both leave the bar where it was.
if (travel > kTouchSlop) return;
if (event.timeStamp - _tapDownTime >= kLongPressTimeout) return;
// A tap that acts on the page — a link, a form — runs as the gesture
// arena settles after this event, so the decision waits for the frame
// that carries it.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// A tap that opened something, or that put the focus in a field of
// the page, is not a tap on the reading surface.
if (_tapActed) return;
if (_focusInPageField()) return;
setState(() => _chromeVisible = !_chromeVisible);
});
}
/// Whether a page field, such as a search box, holds the focus: a tap
/// that gave it the focus is a tap into a form, not on the page.
bool _focusInPageField() => FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>() !=
null;
/// Runs [action] for a control in one of the chrome's own error pages;
/// pressing it acts on the page, so the bar keeps its place.
VoidCallback _press(VoidCallback action) => () {
_tapActed = true;
action();
};
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
final reader = Scaffold(
body: Column(
children: [
AnimatedSlide(
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: AnimatedSize(
// A phone draws under the status bar, so the chrome starts below it;
// the reader content above and beside it already reaches the edges.
body: SafeArea(
top: true,
bottom: false,
left: false,
right: false,
child: Column(
children: [
AnimatedSlide(
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _chromeVisible
? AddressBar(focusNode: _addressFocus)
: const SizedBox(width: double.infinity),
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _chromeVisible
? AddressBar(focusNode: _addressFocus)
: const SizedBox(width: double.infinity),
),
),
),
Expanded(
child: SignalBuilder(builder: (context) {
final browser = deps.browser;
final page = browser.currentPage.value;
// The swipe peek uses the current page's palette when there is
// one, so the neighbor slides in on matching paper.
final peekBackground = page == null
? Theme.of(context).scaffoldBackgroundColor
: paletteFor(
pageVariant: page.variant,
readerVariant: deps.settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
).background;
Expanded(
child: SignalBuilder(builder: (context) {
final browser = deps.browser;
final page = browser.currentPage.value;
// The swipe peek uses the current page's palette when there is
// one, so the neighbor slides in on matching paper.
final peekBackground = page == null
? Theme.of(context).scaffoldBackgroundColor
: paletteFor(
pageVariant: page.variant,
readerVariant: deps.settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
).background;
// The 40em column plus its padding: swipes are accepted
// anywhere in the margin beside it.
final contentWidth =
42 * 17.0 * deps.settings.fontScale.value;
// The 40em column plus its padding: swipes are accepted
// anywhere in the margin beside it.
final contentWidth =
42 * 17.0 * deps.settings.fontScale.value;
return SwipeNavigator(
canGoBack: browser.canGoBack,
canGoForward: browser.canGoForward,
backView: _neighborView(browser.backPage.value),
forwardView: _neighborView(browser.forwardPage.value),
backTitle: browser.backPage.value?.title,
forwardTitle: browser.forwardPage.value?.title,
background: peekBackground,
contentWidth: page == null ? null : contentWidth,
onBack: browser.back,
onForward: browser.forward,
child: Stack(
children: [
Positioned.fill(child: _body(context, page)),
if (browser.loading.value)
const Positioned(
top: 8,
left: 0,
right: 0,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
);
}),
),
],
return SwipeNavigator(
canGoBack: browser.canGoBack,
canGoForward: browser.canGoForward,
backView: _neighborView(browser.backPage.value),
forwardView: _neighborView(browser.forwardPage.value),
backTitle: browser.backPage.value?.title,
forwardTitle: browser.forwardPage.value?.title,
background: peekBackground,
contentWidth: page == null ? null : contentWidth,
// A touch host has no margin beside the column, so there
// the page itself carries the back and forward swipes.
swipesAnywhere: _isTouch,
onBack: browser.back,
onForward: browser.forward,
// Opaque, so a tap on a blank stretch of the page counts
// too; the widgets inside keep their own taps.
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPageTapDown,
onPointerUp: _onPageTapUp,
child: Stack(
children: [
Positioned.fill(child: _body(context, page)),
if (browser.loading.value)
const Positioned(
top: 8,
left: 0,
right: 0,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
),
);
}),
),
],
),
),
);
@ -252,9 +326,14 @@ class _ReaderScreenState extends State<ReaderScreen> {
fontFamily: font,
fontScale: fontScale,
visitedUrls: visited,
onLinkTap: (url) => deps.browser.open(url),
onFormSubmit: (form, values) =>
deps.browser.submitForm(form.action, form.method, values),
onLinkTap: (url) {
_tapActed = true;
deps.browser.open(url);
},
onFormSubmit: (form, values) {
_tapActed = true;
deps.browser.submitForm(form.action, form.method, values);
},
onFormChange: (form, values) =>
deps.browser.narrowWhileTyping(form.action, values),
fetchImageBytes: deps.browser.fetchImageBytes,
@ -322,7 +401,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
],
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
@ -355,7 +434,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
const SizedBox(height: 16),
if (held.isEmpty)
FilledButton(
onPressed: () => deps.browser.open(MewsUrls.certificates),
onPressed: _press(() => deps.browser.open(MewsUrls.certificates)),
child: Text(strings.t('certificates-add')),
)
else
@ -363,13 +442,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FilledButton.tonal(
onPressed: () => deps.browser.chooseCertificate(certificate.id),
onPressed: _press(() => deps.browser.chooseCertificate(certificate.id)),
child: Text(certificate.name),
),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
@ -395,12 +474,12 @@ class _ReaderScreenState extends State<ReaderScreen> {
Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.trustPresentedCertificate(),
onPressed: _press(() => deps.browser.trustPresentedCertificate()),
child: Text(strings.t('error-certificate-trust')),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
@ -457,13 +536,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
Text(strings.t('error-load-failed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
if (canOpenOutside) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _openOutside(failed),
onPressed: _press(() => _openOutside(failed)),
child: Text(strings.t('error-open-in-browser')),
),
],
@ -485,7 +564,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
Text(strings.t('error-broken-page'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => setState(() => _showBrokenText = true),
onPressed: _press(() => setState(() => _showBrokenText = true)),
child: Text(strings.t('error-broken-page-show-text')),
),
],

View file

@ -1,5 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart'
show kLongPressTimeout, kTouchSlop, PointerDeviceKind;
import 'package:flutter/material.dart';
/// Wraps the current page and turns the back/forward swipes into visible page
@ -22,6 +24,7 @@ class SwipeNavigator extends StatefulWidget {
this.backTitle,
this.forwardTitle,
this.contentWidth,
this.swipesAnywhere = false,
super.key,
});
@ -41,6 +44,15 @@ class SwipeNavigator extends StatefulWidget {
/// the base edge zone.
final double? contentWidth;
/// Whether the page itself answers horizontal drags, not only the strips.
/// A touch host has no margin beside the column to swipe in, so there the
/// whole surface navigates: the finger is tracked outside the gesture
/// arena, because a selectable page's own recognizer wins every
/// horizontal drag over its text there. A swipe crosses the slop sideways
/// before the long press that starts a selection completes, so the two
/// never compete for the same finger.
final bool swipesAnywhere;
final Color background;
final VoidCallback onBack;
final VoidCallback onForward;
@ -71,6 +83,14 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
bool _dragging = false;
double _width = 0;
// The raw touch swipe's finger, tracked outside the gesture arena.
int? _trackedPointer;
Offset _downPosition = Offset.zero;
Duration _downTime = Duration.zero;
Offset _lastPosition = Offset.zero;
Duration _lastMoveTime = Duration.zero;
double _swipeVelocity = 0;
@override
void dispose() {
_settle.dispose();
@ -99,12 +119,15 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
setState(() => _drag += details.delta.dx);
}
void _onDragEnd(DragEndDetails details) {
void _onDragEnd(DragEndDetails details) =>
_commitSwipe(details.primaryVelocity ?? 0);
void _commitSwipe(double velocity) {
if (!_dragging) return;
final dx = _displayed;
final velocity = details.primaryVelocity ?? 0;
final goBack = widget.canGoBack && (dx > _width * 0.3 || velocity > 500);
final goForward = widget.canGoForward && (dx < -_width * 0.3 || velocity < -500);
final goForward =
widget.canGoForward && (dx < -_width * 0.3 || velocity < -500);
_animateTo(
goBack
@ -119,6 +142,71 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
);
}
// Raw touch tracking ------------------------------------------------------
void _onPagePointerDown(PointerDownEvent event) {
if (_trackedPointer != null || _dragging || _settledAnimating) return;
// A mouse drives a pointer host's strips, not this listener.
if (event.kind == PointerDeviceKind.mouse) return;
_trackedPointer = event.pointer;
_downPosition = event.position;
_downTime = event.timeStamp;
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
_swipeVelocity = 0;
}
void _onPagePointerMove(PointerMoveEvent event) {
if (_trackedPointer != event.pointer) return;
final total = event.position - _downPosition;
if (!_dragging) {
if (total.distance <= kTouchSlop) {
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
return;
}
// The gate between a swipe and a selection: a finger that crosses the
// slop sideways, before the long press that starts a selection
// completes, is swiping. One that held still that long, or traveled
// along the page first, belongs to text selection or to scrolling.
final stillInSwipeWindow =
event.timeStamp - _downTime < kLongPressTimeout;
final sideways = total.dx.abs() > total.dy.abs();
if (!stillInSwipeWindow || !sideways) {
_trackedPointer = null;
return;
}
setState(() {
_dragging = true;
_drag = total.dx;
});
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
return;
}
setState(() => _drag += event.position.dx - _lastPosition.dx);
final elapsed = event.timeStamp - _lastMoveTime;
if (elapsed > Duration.zero) {
_swipeVelocity =
(event.position.dx - _lastPosition.dx) * 1e6 / elapsed.inMicroseconds;
}
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
}
void _finishPagePointer(PointerEvent event, {required bool commit}) {
if (_trackedPointer != event.pointer) return;
_trackedPointer = null;
if (!_dragging) return;
if (commit) {
_commitSwipe(_swipeVelocity);
} else {
_animateTo(0);
}
}
/// The page offset shown: full travel when a page waits in that direction,
/// a rubber band when none does, capped near the screen width.
double get _displayed {
@ -182,13 +270,36 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
if (peek != null) Positioned.fill(child: peek),
Transform.translate(
offset: Offset(dx, 0),
child: widget.child,
// Opaque, so a drag over a blank stretch of an error page
// navigates too; the widgets inside still take their own taps,
// scrolls and long presses first.
// The raw listener owns the touch swipe: a selectable page's
// own recognizer wins every horizontal drag over its text in
// the arena, so the finger is tracked outside it. Opaque, so
// a drag over a blank stretch of an error page navigates too;
// the widgets inside keep their own taps, scrolls and long
// presses.
child: widget.swipesAnywhere
? Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPagePointerDown,
onPointerMove: _onPagePointerMove,
onPointerUp: (event) => _finishPagePointer(event, commit: true),
onPointerCancel: (event) =>
_finishPagePointer(event, commit: false),
child: widget.child,
)
: widget.child,
),
// Horizontal drags in the strips navigate; elsewhere they select
// text. The strips are translucent, so taps, vertical scrolling
// and mid-page selection still pass through everywhere.
_edgeStrip(fromLeft: true, width: _stripWidth(_width)),
_edgeStrip(fromLeft: false, width: _stripWidth(_width)),
// and mid-page selection still pass through everywhere. A touch
// host navigates with the raw listener instead; strips there
// would feed the same drag twice.
if (!widget.swipesAnywhere) ...[
_edgeStrip(fromLeft: true, width: _stripWidth(_width)),
_edgeStrip(fromLeft: false, width: _stripWidth(_width)),
],
],
);
},

View file

@ -485,6 +485,61 @@ void main() {
expect(find.byType(AddressBar), findsOneWidget);
});
testWidgets('a short tap on the page brings a hidden address bar back',
(tester) async {
await pumpApp(tester);
// A page long enough to scroll.
final tall = pageSource.replaceFirst(
'</body>',
'<p>Padding paragraph to make the page scroll.</p>' * 80 + '</body>');
final page = parseSource(tall, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
// Scrolling down hides the address bar.
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(find.byType(AddressBar), findsNothing);
// A short tap on the page text returns it, the way a reader reaches
// for it on a page that has nothing left to scroll.
await tester.tapAt(tester.getCenter(find.byType(ListView)));
await tester.pumpAndSettle();
expect(find.byType(AddressBar), findsOneWidget);
// A short tap hides it again.
await tester.tapAt(tester.getCenter(find.byType(ListView)));
await tester.pumpAndSettle();
expect(find.byType(AddressBar), findsNothing);
});
testWidgets('a tap that opens a link keeps the address bar where it was',
(tester) async {
final sources = {
'https://example.com/index.html': _page(
'Index', '<h1>Index</h1><p><a href="next.html">The next page</a></p>'),
'https://example.com/next.html':
_page('Next', '<h1>Next</h1><p>The second page.</p>'),
};
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch(sources),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
await stubDeps.browser.open('https://example.com/index.html');
await tester.pumpAndSettle();
// The tap follows a link, so it does not spend itself on the chrome.
await tapRun(tester, 'The next page');
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/next.html');
expect(find.byType(AddressBar), findsOneWidget);
});
testWidgets('swiping back shows the previous page during the drag',
(tester) async {
final sources = {
@ -528,6 +583,102 @@ void main() {
expect(find.text('https://example.com/a.html'), findsOneWidget);
});
testWidgets('a swipe across the body navigates on a touch host',
(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),
strings: strings,
);
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');
await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null);
// The drag starts over the body text, not in an edge margin; a swipe
// crosses the slop before the long press that starts a selection.
final center = tester.getCenter(find.byType(SwipeNavigator));
final gesture = await tester.startGesture(center);
for (var i = 0; i < 6; i++) {
await gesture.moveBy(const Offset(70, 0));
await tester.pump();
}
await gesture.up();
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
});
testWidgets('a long press on body text keeps its drag for selection',
(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),
strings: strings,
);
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');
await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null);
// Hold on the paragraph long enough for the selection to begin, then
// drag sideways: the selection keeps the finger, the page stays put.
final text = tester.getCenter(find.textContaining('The second pages content'));
final gesture = await tester.startGesture(text);
await tester.pump(const Duration(milliseconds: 550));
await gesture.moveBy(
const Offset(300, 0),
timeStamp: const Duration(milliseconds: 600),
);
await tester.pump();
// No neighbor peeks in; the swipe never started.
expect(find.text('Page A'), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html');
});
testWidgets('the chrome starts below the status bar on a phone',
(tester) async {
// View padding arrives in physical pixels; the framework scales it by
// the device pixel ratio to the logical inset the reader sits below.
final previous = tester.view.padding;
tester.view.padding = FakeViewPadding(
left: 0,
right: 0,
top: 44 * tester.view.devicePixelRatio,
bottom: 0,
);
addTearDown(() => tester.view.padding = previous);
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch(const {}),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.byType(AddressBar)).dy, 44);
});
testWidgets('falls back to the wap subdomain before showing an error',
(tester) async {
final stubDeps = AppDependencies(

View file

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart' show kLongPressTimeout;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -21,9 +22,11 @@ SwipeNavigator _navigator({
bool canGoBack = true,
bool canGoForward = true,
double? contentWidth,
bool swipesAnywhere = false,
}) =>
SwipeNavigator(
contentWidth: contentWidth,
swipesAnywhere: swipesAnywhere,
canGoBack: canGoBack,
canGoForward: canGoForward,
backTitle: 'Previous page',
@ -124,6 +127,50 @@ void main() {
expect(back, 1);
});
testWidgets('with swipes anywhere, a drag over the page navigates',
(tester) async {
var back = 0;
await tester.pumpWidget(_wrap(_navigator(
onBack: () => back++,
onForward: () {},
swipesAnywhere: true,
)));
// The middle of the page carries the swipe, where a margin would be.
final center = tester.getCenter(find.byType(SwipeNavigator));
final gesture = await tester.startGesture(center);
await gesture.moveBy(const Offset(300, 0));
await tester.pump();
expect(find.text('Previous page'), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
expect(back, 1);
});
testWidgets('a finger held long enough to select keeps its drag',
(tester) async {
var back = 0;
await tester.pumpWidget(_wrap(_navigator(
onBack: () => back++,
onForward: () {},
swipesAnywhere: true,
)));
// Sideways travel after the long-press window belongs to a selection,
// not to the walk.
final center = tester.getCenter(find.byType(SwipeNavigator));
final gesture = await tester.startGesture(center);
await gesture.moveBy(
const Offset(300, 0),
timeStamp: kLongPressTimeout + const Duration(milliseconds: 1),
);
await tester.pump();
expect(find.text('Previous page'), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
expect(back, 0);
});
testWidgets('drags over the content column still belong to selection',
(tester) async {
var back = 0;