diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index 8cd83e8..8730eba 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:actors/actors.dart'; import 'package:drift/drift.dart' show Value; import 'package:signals/signals.dart'; @@ -45,6 +47,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); + bool get canGoBack => _stackIndex > 0; bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; @@ -63,18 +69,21 @@ class BrowserController { _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(url); _stackIndex++; + _updatePeeks(); await _loadCurrent(); } Future back() async { if (!canGoBack) return; _stackIndex--; + _updatePeeks(); await _loadCurrent(); } Future forward() async { if (!canGoForward) return; _stackIndex++; + _updatePeeks(); await _loadCurrent(); } @@ -90,6 +99,7 @@ class BrowserController { _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(page.url); _stackIndex++; + _updatePeeks(); _show(page); case _: error.value = const LoadFailed(); @@ -102,9 +112,23 @@ class BrowserController { if (url == null) return; _stack.add(url); _stackIndex = 0; + _updatePeeks(); 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; + } + + unawaited(peek(_stackIndex - 1).then((t) => backTitle.value = t)); + unawaited(peek(_stackIndex + 1).then((t) => forwardTitle.value = t)); + } + Future _loadCurrent() async { if (_stackIndex < 0) return; final url = _stack[_stackIndex]; diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index e62a828..44a3115 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -12,6 +12,7 @@ import 'fob.dart'; import 'mews_page_view.dart'; import 'palettes.dart'; import 'settings_sheet.dart'; +import 'swipe_navigator.dart'; /// The whole v1 UI: an address bar that hides on scroll, the page reader with /// swipe back/forward, a settings FOB that appears on scroll, and error @@ -86,18 +87,24 @@ class _ReaderScreenState extends State { 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, + ).background; - return GestureDetector( - behavior: HitTestBehavior.deferToChild, - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity ?? 0; - // Swipe right goes back, swipe left goes forward. - if (velocity > 300) { - browser.back(); - } else if (velocity < -300) { - browser.forward(); - } - }, + return SwipeNavigator( + canGoBack: browser.canGoBack, + canGoForward: browser.canGoForward, + backTitle: browser.backTitle.value, + forwardTitle: browser.forwardTitle.value, + background: peekBackground, + onBack: browser.back, + onForward: browser.forward, child: Stack( children: [ Positioned.fill(child: _body(context, page)), diff --git a/app/lib/presentation/reader/swipe_navigator.dart b/app/lib/presentation/reader/swipe_navigator.dart new file mode 100644 index 0000000..7a69e6b --- /dev/null +++ b/app/lib/presentation/reader/swipe_navigator.dart @@ -0,0 +1,218 @@ +import 'dart:math' as math; + +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. +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, + super.key, + }); + + final bool canGoBack; + final bool canGoForward; + final String? backTitle; + final String? forwardTitle; + final Color background; + final VoidCallback onBack; + final VoidCallback onForward; + final Widget child; + + @override + State createState() => _SwipeNavigatorState(); +} + +class _SwipeNavigatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _settle; + + @override + void initState() { + super.initState(); + // Created eagerly: a lazy field would initialize during dispose() when no + // swipe ever happened. + _settle = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + } + Animation? _settling; + + /// Raw finger travel; the displayed offset clamps and rubber-bands this. + double _drag = 0; + bool _dragging = false; + double _width = 0; + + @override + void dispose() { + _settle.dispose(); + super.dispose(); + } + + bool get _settledAnimating => _settling != null && _settle.isAnimating; + + void _onDragStart(DragStartDetails details) { + if (_settledAnimating) return; + setState(() => _dragging = true); + } + + void _onDragUpdate(DragUpdateDetails details) { + if (!_dragging) return; + setState(() => _drag += details.delta.dx); + } + + void _onDragEnd(DragEndDetails details) { + 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); + + _animateTo( + goBack + ? _width + : goForward + ? -_width + : 0, + onSettled: () { + if (goBack) widget.onBack(); + if (goForward) widget.onForward(); + }, + ); + } + + /// 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 { + final drag = _drag; + if (drag > 0 && !widget.canGoBack) { + return math.min(drag * 0.25, 96.0); + } + if (drag < 0 && !widget.canGoForward) { + return math.max(drag * 0.25, -96.0); + } + final max = _width * 0.85; + return drag.clamp(-max, max).toDouble(); + } + + void _animateTo(double target, {VoidCallback? onSettled}) { + setState(() => _dragging = false); + final from = _displayed; + _settling = Tween(begin: from, end: target).animate( + CurvedAnimation(parent: _settle, curve: Curves.easeOutCubic), + )..addListener(() { + if (_settle.isAnimating) { + setState(() => _drag = _settling!.value); + } + }); + _settle.forward(from: 0).whenComplete(() { + setState(() => _drag = 0); + _settling = null; + onSettled?.call(); + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _width = constraints.maxWidth; + final dx = _displayed; + + // Only directions with a page to show reveal a peek; the others + // 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, + ) + : const SizedBox.shrink(); + + return GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: Stack( + children: [ + Positioned.fill(child: peek), + Transform.translate( + offset: Offset(dx, 0), + child: widget.child, + ), + ], + ), + ); + }, + ); + } +} + +/// The neighboring page sliding in under the current one. +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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/test/swipe_navigator_test.dart b/app/test/swipe_navigator_test.dart new file mode 100644 index 0000000..b12ef65 --- /dev/null +++ b/app/test/swipe_navigator_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/presentation/reader/swipe_navigator.dart'; + +Widget _wrap(SwipeNavigator navigator) => MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + height: 600, + child: navigator, + ), + ), + ), + ); + +SwipeNavigator _navigator({ + required VoidCallback onBack, + required VoidCallback onForward, + bool canGoBack = true, + bool canGoForward = true, +}) => + SwipeNavigator( + canGoBack: canGoBack, + canGoForward: canGoForward, + backTitle: 'Previous page', + forwardTitle: 'Next page', + background: const Color(0xfffaf8f3), + onBack: onBack, + onForward: onForward, + child: const ColoredBox( + color: Color(0xff18191b), + child: SizedBox.expand(), + ), + ); + +void main() { + testWidgets('dragging right reveals the previous page and commits', + (tester) async { + var back = 0; + var forward = 0; + await tester.pumpWidget(_wrap(_navigator( + onBack: () => back++, + onForward: () => forward++, + ))); + + final center = tester.getCenter(find.byType(SwipeNavigator)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + + // The neighbor is peeking in with its title while the page slides away. + expect(find.text('Previous page'), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(back, 1); + expect(forward, 0); + }); + + testWidgets('dragging left commits forward navigation', (tester) async { + var back = 0; + var forward = 0; + await tester.pumpWidget(_wrap(_navigator( + onBack: () => back++, + onForward: () => forward++, + ))); + + 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('Next page'), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(forward, 1); + expect(back, 0); + }); + + testWidgets('a short drag springs back without navigating', (tester) async { + var back = 0; + await tester.pumpWidget(_wrap(_navigator( + onBack: () => back++, + onForward: () {}, + ))); + + final center = tester.getCenter(find.byType(SwipeNavigator)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(back, 0); + // The neighbor disappeared again. + expect(find.text('Previous page'), findsNothing); + }); + + testWidgets('directions without pages rubber-band and never commit', + (tester) async { + var back = 0; + var forward = 0; + await tester.pumpWidget(_wrap(_navigator( + onBack: () => back++, + onForward: () => forward++, + canGoBack: false, + canGoForward: false, + ))); + + final center = tester.getCenter(find.byType(SwipeNavigator)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(400, 0)); + await tester.pump(); + // No neighbor title shows when there is nothing to go back to. + expect(find.text('Previous page'), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(back, 0); + expect(forward, 0); + }); +}