From bc617deeaf2ae684f20c1000ed7cd52f9a61d0a7 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Tue, 6 Oct 2026 16:31:58 +0300 Subject: [PATCH 1/2] feat: expose generic reader activity and visible content coverage --- docs/integration.md | 16 +++++++ lib/papyrus_reader.dart | 1 + lib/src/controller/reader_controller.dart | 8 ++++ lib/src/domain/reader_activity.dart | 38 ++++++++++++++++ lib/src/domain/reader_snapshot.dart | 13 ++++++ lib/src/engine/epub/epub_reader_engine.dart | 50 +++++++++++++++++++++ lib/src/engine/epub/epub_viewport.dart | 40 +++++++++++++++++ lib/src/engine/pdf/pdf_facade.dart | 14 ++++++ lib/src/engine/pdf/pdf_reader_engine.dart | 39 ++++++++++++++++ lib/src/presentation/papyrus_reader.dart | 30 +++++++++++++ test/presentation/papyrus_reader_test.dart | 32 +++++++++++++ 11 files changed, 281 insertions(+) create mode 100644 lib/src/domain/reader_activity.dart diff --git a/docs/integration.md b/docs/integration.md index a38a80b..3dda55e 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -45,3 +45,19 @@ loads the worker through `rootBundle` and creates a local Blob URL. A restrictiv Content Security Policy must allow `worker-src blob:`; do not fall back silently to main-thread parsing if worker initialization fails. Offline deployment must cache the packaged worker alongside other Flutter assets. + +## Optional activity observations + +`PapyrusReader(onActivity: ...)` reports `ReaderActivityEvent` readiness, visibility, +locator, navigation cause, exposed stable coverage, and end-of-document state. +The callback is optional and independent of Goals. Existing locator callbacks and +version-1 locators remain unchanged. Hosts own clocks, foreground lifecycle, +completion confirmation, persistence, and aggregation. + +Settings and contents panels mark content obscured. EPUB coverage uses normalized +chapter UTF-16 content extents, with spine count for host calibration; PDF coverage +lists actually exposed page indices, including spreads. Jumps never expose skipped +pages. Engines without page metrics retain time tracking through ready snapshots. +Parsing remains in the existing worker/isolate transports; activity reporting does +not require whole-book layout. Reflow reports coverage without changing the +restored locator's precise content offset. diff --git a/lib/papyrus_reader.dart b/lib/papyrus_reader.dart index b7909d8..7da16b2 100644 --- a/lib/papyrus_reader.dart +++ b/lib/papyrus_reader.dart @@ -2,6 +2,7 @@ library; export 'src/controller/reader_controller.dart'; export 'src/domain/reader_capabilities.dart'; +export 'src/domain/reader_activity.dart'; export 'src/domain/reader_document.dart'; export 'src/domain/reader_exception.dart'; export 'src/domain/reader_locator.dart'; diff --git a/lib/src/controller/reader_controller.dart b/lib/src/controller/reader_controller.dart index 442004b..cfa3f72 100644 --- a/lib/src/controller/reader_controller.dart +++ b/lib/src/controller/reader_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../domain/reader_document.dart'; +import '../domain/reader_activity.dart'; import '../domain/reader_exception.dart'; import '../domain/reader_locator.dart'; import '../domain/reader_preferences.dart'; @@ -32,6 +33,7 @@ final class ReaderController extends ChangeNotifier { bool _isDisposed = false; ReaderSnapshot get snapshot => _snapshot; + ReaderNavigationCause navigationCause = ReaderNavigationCause.restore; ReaderPreferences get preferences => _snapshot.preferences; @@ -57,6 +59,7 @@ final class ReaderController extends ChangeNotifier { candidate.dispose(); } _loadingEngines.clear(); + navigationCause = ReaderNavigationCause.restore; final request = _ReaderLoadRequest( id: ++_latestLoadId, document: document, @@ -126,6 +129,7 @@ final class ReaderController extends ChangeNotifier { } Future goTo(ReaderLocator locator) async { + navigationCause = ReaderNavigationCause.jump; await _requireReadyEngine().goTo(locator); } @@ -138,14 +142,17 @@ final class ReaderController extends ChangeNotifier { ); } + navigationCause = ReaderNavigationCause.jump; await _requireReadyEngine().goToProgress(progress); } Future goNext() async { + navigationCause = ReaderNavigationCause.turn; await _requireReadyEngine().goNext(); } Future goPrevious() async { + navigationCause = ReaderNavigationCause.turn; await _requireReadyEngine().goPrevious(); } @@ -154,6 +161,7 @@ final class ReaderController extends ChangeNotifier { } Future updatePreferences(ReaderPreferences preferences) async { + navigationCause = ReaderNavigationCause.reflow; await _requireReadyEngine().updatePreferences(preferences); _syncFromEngine(); } diff --git a/lib/src/domain/reader_activity.dart b/lib/src/domain/reader_activity.dart new file mode 100644 index 0000000..896c029 --- /dev/null +++ b/lib/src/domain/reader_activity.dart @@ -0,0 +1,38 @@ +import 'reader_locator.dart'; + +/// Navigation is descriptive; hosts must not count skipped locator distances. +enum ReaderNavigationCause { restore, turn, jump, reflow, viewport } + +/// Stable visible content. EPUB extents use normalized chapter UTF-16 offsets. +final class ReaderContentCoverage { + const ReaderContentCoverage({ + required this.key, + required this.start, + required this.end, + this.chapterCount, + this.pdfPageIndex, + }); + final String key; + final double start; + final double end; + final int? chapterCount; + final int? pdfPageIndex; +} + +/// Generic observation only. The host owns timing, persistence, and goal rules. +final class ReaderActivityEvent { + const ReaderActivityEvent({ + required this.ready, + required this.visible, + required this.cause, + this.locator, + this.coverage = const [], + this.atEnd = false, + }); + final bool ready; + final bool visible; + final ReaderNavigationCause cause; + final ReaderLocator? locator; + final List coverage; + final bool atEnd; +} diff --git a/lib/src/domain/reader_snapshot.dart b/lib/src/domain/reader_snapshot.dart index 0c45750..472f541 100644 --- a/lib/src/domain/reader_snapshot.dart +++ b/lib/src/domain/reader_snapshot.dart @@ -1,4 +1,5 @@ import 'reader_capabilities.dart'; +import 'reader_activity.dart'; import 'reader_document.dart'; import 'reader_exception.dart'; import 'reader_locator.dart'; @@ -24,6 +25,9 @@ sealed class ReaderSnapshot { ReaderException? get error; String? get locationLabel => null; + List get coverage => const []; + bool get contentReady => status == ReaderStatus.ready; + bool get atEnd => false; } final class ReaderIdleSnapshot extends ReaderSnapshot { @@ -88,11 +92,20 @@ final class ReaderReadySnapshot extends ReaderSnapshot { this.locator, List toc = const [], this.locationLabel, + this.coverage = const [], + this.contentReady = true, + this.atEnd = false, }) : toc = List.unmodifiable(toc); @override ReaderStatus get status => ReaderStatus.ready; + @override + final List coverage; + @override + final bool contentReady; + @override + final bool atEnd; @override final String? locationLabel; diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index c5c9d5e..104e7bb 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../../domain/reader_capabilities.dart'; +import '../../domain/reader_activity.dart'; import '../../domain/reader_document.dart'; import '../../domain/reader_exception.dart'; import '../../domain/reader_locator.dart'; @@ -45,6 +46,9 @@ final class EpubReaderEngine extends ReaderEngine { bool _disposed = false; int _visiblePage = 1; int _pageCount = 0; + List _coverage = const []; + bool _viewportReady = false; + bool _atEnd = false; /// The mounted viewport handles local page/scroll turns. The engine crosses /// the spine only when that viewport reports a boundary. @@ -169,6 +173,9 @@ final class EpubReaderEngine extends ReaderEngine { } void _setChapter(Map chapter, EpubReaderLocator target) { + _coverage = const []; + _viewportReady = false; + _atEnd = false; _currentChapterHtml = chapter['html'] as String; _blocks = [ for (final block in chapter['blocks'] as List) @@ -212,6 +219,9 @@ final class EpubReaderEngine extends ReaderEngine { ); } final generation = ++_navigationGeneration; + _coverage = const []; + _viewportReady = false; + _atEnd = false; if (locator.spineIndex == _locator?.spineIndex) { final offset = locator.anchor == null ? locator.textOffset @@ -291,6 +301,40 @@ final class EpubReaderEngine extends ReaderEngine { _publish(); } + void viewportCoverageChanged(int start, int end, {bool atEnd = false}) { + if (_disposed || _locator == null) return; + final coverage = _contentLength > 0 && end > start + ? ReaderContentCoverage( + key: 'epub:${_locator!.spineIndex}:$_contentLength', + start: start / _contentLength, + end: (end / _contentLength).clamp(0, 1), + chapterCount: _chapterCount, + ) + : null; + final nextEnd = atEnd && _locator!.spineIndex == _chapterCount - 1; + if (_viewportReady && + _atEnd == nextEnd && + (coverage == null + ? _coverage.isEmpty + : _coverage.length == 1 && + _coverage.first.key == coverage.key && + _coverage.first.start == coverage.start && + _coverage.first.end == coverage.end)) { + return; + } + _viewportReady = true; + _coverage = coverage == null ? const [] : [coverage]; + _atEnd = nextEnd; + _publish(); + } + + void viewportPreparing() { + if (_disposed || !_viewportReady) return; + _viewportReady = false; + _coverage = const []; + _publish(); + } + void viewportPaginationChanged(int count, int visiblePage) { if (_disposed || (_pageCount == count && _visiblePage == visiblePage)) { return; @@ -312,6 +356,9 @@ final class EpubReaderEngine extends ReaderEngine { } if (_preferences == preferences) return; _preferences = preferences; + _coverage = const []; + _viewportReady = false; + _atEnd = false; _restorationRevision++; _publish(); } @@ -320,6 +367,9 @@ final class EpubReaderEngine extends ReaderEngine { if (_disposed) return; snapshot = ReaderReadySnapshot( document: _document!, + coverage: _coverage, + contentReady: _viewportReady, + atEnd: _atEnd, preferences: _preferences, capabilities: capabilities, locator: _locator, diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index c6a2c5c..4fdf5ce 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -119,6 +119,11 @@ final class _EpubViewportState extends State { _busy = false; if (_layoutReady?.isCompleted == false) _layoutReady!.complete(); widget.engine.viewportPaginationChanged(pages.length, target + 1); + if (widget.engine.preferences.layoutMode == ReaderLayoutMode.paginated) { + _publishCoverage(_pageIndex * _columns); + } else { + _scrollChanged(); + } }); } @@ -159,6 +164,18 @@ final class _EpubViewportState extends State { textOffset: _pages[page].offset, pageNumber: page + 1, ); + _publishCoverage(page); + } + + void _publishCoverage(int page) { + final after = math.min(page + _columns, _pages.length); + widget.engine.viewportCoverageChanged( + _pages[page].offset, + after < _pages.length + ? _pages[after].offset + : widget.engine.contentLength, + atEnd: after == _pages.length, + ); } void _scrollChanged() { @@ -185,6 +202,21 @@ final class _EpubViewportState extends State { textOffset: offset, pageNumber: index + 1, ); + final bottom = pixels + _scrollController.position.viewportDimension; + var lastOffset = offset; + for (var i = index; i < _pages.length && _scrollOffsets[i] < bottom; i++) { + var top = _scrollOffsets[i] + widget.engine.preferences.pageMargins.top; + for (final fragment in _pages[i].fragments) { + if (top >= bottom) break; + lastOffset = fragment.block.offset + fragment.end; + top += fragment.height + fragment.spacing(widget.engine.preferences); + } + } + widget.engine.viewportCoverageChanged( + offset, + lastOffset.clamp(0, widget.engine.contentLength), + atEnd: pixels >= max - 1, + ); } void _beyondChapter(int direction) { @@ -219,6 +251,9 @@ final class _EpubViewportState extends State { final preferences = widget.engine.preferences; if (preferences.layoutMode == ReaderLayoutMode.scroll && widget.engine.renderer != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.engine.viewportCoverageChanged(0, 0); + }); return EpubScrollViewport( xhtml: widget.engine.currentChapterHtml, preferences: preferences, @@ -265,6 +300,11 @@ final class _EpubViewportState extends State { ); if (_layoutKey != key) { _layoutKey = key; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _layoutKey == key && _busy) { + widget.engine.viewportPreparing(); + } + }); _restoration = -1; _restoring = true; final generation = ++_layoutGeneration; diff --git a/lib/src/engine/pdf/pdf_facade.dart b/lib/src/engine/pdf/pdf_facade.dart index 609b766..ff12599 100644 --- a/lib/src/engine/pdf/pdf_facade.dart +++ b/lib/src/engine/pdf/pdf_facade.dart @@ -38,6 +38,7 @@ final class PdfViewportConfiguration { required this.layoutMode, required this.onPageChanged, this.onPositionChanged, + this.onVisiblePagesChanged, }); final int pageIndex; @@ -47,6 +48,7 @@ final class PdfViewportConfiguration { final ReaderLayoutMode layoutMode; final ValueChanged onPageChanged; final void Function(int pageIndex, double pageOffset)? onPositionChanged; + final ValueChanged>? onVisiblePagesChanged; } abstract interface class PdfFacade { @@ -373,6 +375,18 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { 0.0, 1.0, ); + final visible = _controller.visibleRect; + final exposed = []; + for (var i = 0; i < _controller.layout.pageLayouts.length; i++) { + final rect = _controller.layout.pageLayouts[i]; + if (!rect.overlaps(visible)) continue; + final intersection = rect.intersect(visible); + if (intersection.width * intersection.height >= + rect.width * rect.height * .25) { + exposed.add(i); + } + } + config?.onVisiblePagesChanged?.call(exposed); _pageIndex = index; _pageOffset = offset; if (config?.onPositionChanged != null) { diff --git a/lib/src/engine/pdf/pdf_reader_engine.dart b/lib/src/engine/pdf/pdf_reader_engine.dart index 8667dd4..2d37700 100644 --- a/lib/src/engine/pdf/pdf_reader_engine.dart +++ b/lib/src/engine/pdf/pdf_reader_engine.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../../domain/reader_capabilities.dart'; +import '../../domain/reader_activity.dart'; import '../../domain/reader_document.dart'; import '../../domain/reader_exception.dart'; import '../../domain/reader_locator.dart'; @@ -58,6 +59,8 @@ final class PdfReaderEngine extends ReaderEngine { ); } + _exposedPages = const []; + _customViewportReady = false; PdfFacade? candidate; try { final bytes = await document.loadBytes(); @@ -176,6 +179,8 @@ final class PdfReaderEngine extends ReaderEngine { 'The locator is not valid for the current PDF.', ); } + _exposedPages = const []; + _publish(); await facade.showPage(locator.pageIndex, locator.pageOffset); _locator = locator; _publish(); @@ -280,10 +285,36 @@ final class PdfReaderEngine extends ReaderEngine { _publish(); } + List _exposedPages = const []; + bool _customViewportReady = false; + void _visiblePagesChanged(List pages) { + if (_disposed) return; + if (pages.length == _exposedPages.length && + List.generate( + pages.length, + (i) => pages[i] == _exposedPages[i], + ).every((same) => same)) { + return; + } + _exposedPages = List.unmodifiable(pages); + _publish(); + } + void _publish() { if (_disposed) return; snapshot = ReaderReadySnapshot( document: _document!, + contentReady: _exposedPages.isNotEmpty || _customViewportReady, + coverage: [ + for (final page in _exposedPages) + ReaderContentCoverage( + key: 'pdf:$page', + start: 0, + end: 1, + pdfPageIndex: page, + ), + ], + atEnd: _exposedPages.contains(_facade!.pageCount - 1), preferences: _preferences, capabilities: capabilities, locator: _locator, @@ -311,6 +342,13 @@ final class PdfReaderEngine extends ReaderEngine { ReaderColumnMode.automatic => constraints.maxWidth >= 1000, }; _facingPages = facingPages; + if (_facadeFactory != null && !_customViewportReady) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_disposed || !identical(_facade, facade)) return; + _customViewportReady = true; + _publish(); + }); + } return facade.buildViewport( PdfViewportConfiguration( pageIndex: locator.pageIndex, @@ -320,6 +358,7 @@ final class PdfReaderEngine extends ReaderEngine { layoutMode: _preferences.layoutMode, onPageChanged: _visiblePageChanged, onPositionChanged: _visiblePositionChanged, + onVisiblePagesChanged: _visiblePagesChanged, ), ); }, diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index fadf87e..c5d42f6 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import '../controller/reader_controller.dart'; import '../domain/reader_document.dart'; +import '../domain/reader_activity.dart'; import '../domain/reader_exception.dart'; import '../domain/reader_locator.dart'; import '../domain/reader_preferences.dart'; @@ -30,6 +31,7 @@ final class PapyrusReader extends StatefulWidget { this.theme, this.controller, this.registry, + this.onActivity, this.onLocatorChanged, this.onPreferencesChanged, this.onBack, @@ -46,6 +48,7 @@ final class PapyrusReader extends StatefulWidget { final ReaderThemeData? theme; final ReaderController? controller; final ReaderEngineRegistry? registry; + final ValueChanged? onActivity; final ReaderLocatorChanged? onLocatorChanged; final ReaderPreferencesChanged? onPreferencesChanged; final VoidCallback? onBack; @@ -268,6 +271,29 @@ final class _PapyrusReaderState extends State { } setState(() {}); + _reportActivity(); + } + + void _reportActivity() { + if (!mounted) return; + final snapshot = _controller.snapshot; + _invokeHostCallback( + () => widget.onActivity?.call( + ReaderActivityEvent( + ready: snapshot.contentReady, + visible: + snapshot.contentReady && + _panel == _ReaderPanel.none && + _compactPanelRoute == null && + (ModalRoute.of(context)?.isCurrent ?? true), + cause: _controller.navigationCause, + locator: snapshot.locator, + coverage: snapshot.coverage, + atEnd: snapshot.atEnd, + ), + ), + 'onActivity', + ); } void _invokeHostCallback(VoidCallback callback, String name) { @@ -302,6 +328,7 @@ final class _PapyrusReaderState extends State { @override Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => _reportActivity()); final snapshot = _controller.snapshot; final ambient = Theme.of(context); final readerTheme = snapshot.status == ReaderStatus.ready @@ -855,6 +882,7 @@ final class _PapyrusReaderState extends State { ); _compactPanelRoute = route; _compactPanelNavigator = navigator; + _reportActivity(); final sheet = navigator.push(route); unawaited( sheet.then( @@ -862,12 +890,14 @@ final class _PapyrusReaderState extends State { if (identical(_compactPanelRoute, route)) { _compactPanelRoute = null; _compactPanelNavigator = null; + _reportActivity(); } }, onError: (Object error, StackTrace stackTrace) { if (identical(_compactPanelRoute, route)) { _compactPanelRoute = null; _compactPanelNavigator = null; + _reportActivity(); } FlutterError.reportError( FlutterErrorDetails( diff --git a/test/presentation/papyrus_reader_test.dart b/test/presentation/papyrus_reader_test.dart index d727bbd..bf6f8d6 100644 --- a/test/presentation/papyrus_reader_test.dart +++ b/test/presentation/papyrus_reader_test.dart @@ -55,6 +55,7 @@ void main() { double textScale = 1, ReaderUiBuilders builders = const ReaderUiBuilders(), ReaderLocatorChanged? onLocatorChanged, + ValueChanged? onActivity, ReaderPreferencesChanged? onPreferencesChanged, ReaderPreferences? initialPreferences, ReaderThemeData? readerTheme, @@ -93,6 +94,7 @@ void main() { initialPreferences: initialPreferences, theme: readerTheme, onLocatorChanged: onLocatorChanged, + onActivity: onActivity, onPreferencesChanged: onPreferencesChanged, ), ), @@ -136,6 +138,36 @@ void main() { expect(tester.state(viewport), same(state)); }); + testWidgets( + 'generic activity pauses behind panels and observes jumps without page metrics', + (tester) async { + final events = []; + final controller = controllerFor([_UiReaderEngine()]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Activity'), + controller: controller, + size: const Size(1100, 800), + onActivity: events.add, + ); + await tester.pumpAndSettle(); + expect(events.last.ready, isTrue); + expect(events.last.visible, isTrue); + expect(events.last.coverage, isEmpty); + await controller.goToProgress(.5); + await tester.pumpAndSettle(); + expect(events.last.cause, ReaderNavigationCause.jump); + await tester.tap(find.byTooltip('Reading settings')); + await tester.pumpAndSettle(); + expect(events.last.visible, isFalse); + await tester.tap(find.byTooltip('Close panel')); + await tester.pumpAndSettle(); + expect(events.last.visible, isTrue); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('sidebar meets the toolbar border without an empty gap', ( tester, ) async { From 8b60a1ece7103f0751776e416d22639b1e0df950 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 9 Oct 2026 01:59:55 +0300 Subject: [PATCH 2/2] Fix reader activity reporting and serialization --- lib/src/controller/reader_controller.dart | 51 +++++-- lib/src/domain/reader_activity.dart | 152 +++++++++++++++++++- lib/src/domain/reader_snapshot.dart | 5 +- lib/src/engine/pdf/pdf_facade.dart | 44 ++++-- lib/src/engine/pdf/pdf_reader_engine.dart | 24 +++- lib/src/presentation/papyrus_reader.dart | 50 ++++--- test/controller/reader_controller_test.dart | 37 +++++ test/domain/reader_activity_test.dart | 111 ++++++++++++++ test/domain/reader_snapshot_test.dart | 19 +++ test/engine/pdf_reader_engine_test.dart | 49 +++++++ test/presentation/papyrus_reader_test.dart | 43 ++++++ test/support/fake_reader_engine.dart | 5 + 12 files changed, 540 insertions(+), 50 deletions(-) create mode 100644 test/domain/reader_activity_test.dart diff --git a/lib/src/controller/reader_controller.dart b/lib/src/controller/reader_controller.dart index cfa3f72..0ed94b5 100644 --- a/lib/src/controller/reader_controller.dart +++ b/lib/src/controller/reader_controller.dart @@ -33,6 +33,7 @@ final class ReaderController extends ChangeNotifier { bool _isDisposed = false; ReaderSnapshot get snapshot => _snapshot; + ({Object token, ReaderNavigationCause cause})? _navigationCommand; ReaderNavigationCause navigationCause = ReaderNavigationCause.restore; ReaderPreferences get preferences => _snapshot.preferences; @@ -59,6 +60,7 @@ final class ReaderController extends ChangeNotifier { candidate.dispose(); } _loadingEngines.clear(); + _navigationCommand = null; navigationCause = ReaderNavigationCause.restore; final request = _ReaderLoadRequest( id: ++_latestLoadId, @@ -129,8 +131,10 @@ final class ReaderController extends ChangeNotifier { } Future goTo(ReaderLocator locator) async { - navigationCause = ReaderNavigationCause.jump; - await _requireReadyEngine().goTo(locator); + await _navigate( + ReaderNavigationCause.jump, + (engine) => engine.goTo(locator), + ); } Future goToProgress(double progress) async { @@ -142,18 +146,21 @@ final class ReaderController extends ChangeNotifier { ); } - navigationCause = ReaderNavigationCause.jump; - await _requireReadyEngine().goToProgress(progress); + await _navigate( + ReaderNavigationCause.jump, + (engine) => engine.goToProgress(progress), + ); } Future goNext() async { - navigationCause = ReaderNavigationCause.turn; - await _requireReadyEngine().goNext(); + await _navigate(ReaderNavigationCause.turn, (engine) => engine.goNext()); } Future goPrevious() async { - navigationCause = ReaderNavigationCause.turn; - await _requireReadyEngine().goPrevious(); + await _navigate( + ReaderNavigationCause.turn, + (engine) => engine.goPrevious(), + ); } Future currentLocator() async { @@ -161,9 +168,27 @@ final class ReaderController extends ChangeNotifier { } Future updatePreferences(ReaderPreferences preferences) async { - navigationCause = ReaderNavigationCause.reflow; - await _requireReadyEngine().updatePreferences(preferences); - _syncFromEngine(); + await _navigate(ReaderNavigationCause.reflow, (engine) async { + await engine.updatePreferences(preferences); + if (identical(_engine, engine)) _syncFromEngine(); + }); + } + + Future _navigate( + ReaderNavigationCause cause, + Future Function(ReaderEngine) action, + ) async { + final engine = _requireReadyEngine(); + final token = Object(); + _navigationCommand = (token: token, cause: cause); + navigationCause = cause; + try { + await action(engine); + } finally { + if (identical(_navigationCommand?.token, token)) { + _navigationCommand = null; + } + } } void _selectEngine(ReaderEngine engine) { @@ -199,6 +224,10 @@ final class ReaderController extends ChangeNotifier { final engine = _engine; if (engine != null) { + if (engine.snapshot.locator != _snapshot.locator) { + navigationCause = + _navigationCommand?.cause ?? ReaderNavigationCause.viewport; + } _publish(engine.snapshot); } } diff --git a/lib/src/domain/reader_activity.dart b/lib/src/domain/reader_activity.dart index 896c029..6302b1c 100644 --- a/lib/src/domain/reader_activity.dart +++ b/lib/src/domain/reader_activity.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import 'reader_locator.dart'; /// Navigation is descriptive; hosts must not count skipped locator distances. @@ -12,27 +14,171 @@ final class ReaderContentCoverage { this.chapterCount, this.pdfPageIndex, }); + + static const int currentVersion = 1; final String key; final double start; final double end; final int? chapterCount; final int? pdfPageIndex; + + factory ReaderContentCoverage.fromJson(Map json) { + if (json['version'] != currentVersion) { + throw FormatException( + 'Unsupported content coverage version: ${json['version']}.', + ); + } + final key = json['key']; + final start = json['start']; + final end = json['end']; + final chapters = json['chapterCount']; + final page = json['pdfPageIndex']; + if (key is! String || + start is! num || + end is! num || + (chapters != null && chapters is! int) || + (page != null && page is! int)) { + throw const FormatException('Invalid content coverage fields.'); + } + final coverage = ReaderContentCoverage( + key: key, + start: start.toDouble(), + end: end.toDouble(), + chapterCount: chapters as int?, + pdfPageIndex: page as int?, + ); + coverage._validate(); + return coverage; + } + + Map toJson() { + _validate(); + return { + 'version': currentVersion, + 'key': key, + 'start': start, + 'end': end, + if (chapterCount != null) 'chapterCount': chapterCount, + if (pdfPageIndex != null) 'pdfPageIndex': pdfPageIndex, + }; + } + + void _validate() { + if (key.trim().isEmpty || + !start.isFinite || + !end.isFinite || + start < 0 || + end > 1 || + end < start || + (chapterCount != null && chapterCount! <= 0) || + (pdfPageIndex != null && pdfPageIndex! < 0)) { + throw const FormatException( + 'Invalid content coverage range or identity.', + ); + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ReaderContentCoverage && + key == other.key && + start == other.start && + end == other.end && + chapterCount == other.chapterCount && + pdfPageIndex == other.pdfPageIndex; + + @override + int get hashCode => Object.hash(key, start, end, chapterCount, pdfPageIndex); } /// Generic observation only. The host owns timing, persistence, and goal rules. final class ReaderActivityEvent { - const ReaderActivityEvent({ + ReaderActivityEvent({ required this.ready, required this.visible, required this.cause, this.locator, - this.coverage = const [], + List coverage = const [], this.atEnd = false, - }); + }) : coverage = List.unmodifiable(coverage); + + static const int currentVersion = 1; final bool ready; final bool visible; final ReaderNavigationCause cause; final ReaderLocator? locator; final List coverage; final bool atEnd; + + factory ReaderActivityEvent.fromJson(Map json) { + if (json['version'] != currentVersion) { + throw FormatException( + 'Unsupported reader activity version: ${json['version']}.', + ); + } + final ready = json['ready']; + final visible = json['visible']; + final atEnd = json['atEnd']; + final cause = json['cause']; + final locator = json['locator']; + final coverage = json['coverage']; + if (ready is! bool || + visible is! bool || + atEnd is! bool || + coverage is! List || + (locator != null && locator is! Map) || + !ReaderNavigationCause.values.any((value) => value.name == cause) || + coverage.any((value) => value is! Map)) { + throw const FormatException('Invalid reader activity fields.'); + } + return ReaderActivityEvent( + ready: ready, + visible: visible, + atEnd: atEnd, + cause: ReaderNavigationCause.values.firstWhere( + (value) => value.name == cause, + ), + locator: locator == null + ? null + : ReaderLocator.fromJson(locator as Map), + coverage: coverage + .map( + (value) => + ReaderContentCoverage.fromJson(value as Map), + ) + .toList(), + ); + } + + Map toJson() => { + 'version': currentVersion, + 'ready': ready, + 'visible': visible, + 'atEnd': atEnd, + 'cause': cause.name, + if (locator != null) 'locator': locator!.toJson(), + 'coverage': coverage.map((value) => value.toJson()).toList(), + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ReaderActivityEvent && + ready == other.ready && + visible == other.visible && + cause == other.cause && + locator == other.locator && + atEnd == other.atEnd && + listEquals(coverage, other.coverage); + + @override + int get hashCode => Object.hash( + ready, + visible, + cause, + locator, + atEnd, + Object.hashAll(coverage), + ); } diff --git a/lib/src/domain/reader_snapshot.dart b/lib/src/domain/reader_snapshot.dart index 472f541..5550450 100644 --- a/lib/src/domain/reader_snapshot.dart +++ b/lib/src/domain/reader_snapshot.dart @@ -92,10 +92,11 @@ final class ReaderReadySnapshot extends ReaderSnapshot { this.locator, List toc = const [], this.locationLabel, - this.coverage = const [], + List coverage = const [], this.contentReady = true, this.atEnd = false, - }) : toc = List.unmodifiable(toc); + }) : toc = List.unmodifiable(toc), + coverage = List.unmodifiable(coverage); @override ReaderStatus get status => ReaderStatus.ready; diff --git a/lib/src/engine/pdf/pdf_facade.dart b/lib/src/engine/pdf/pdf_facade.dart index ff12599..22b079e 100644 --- a/lib/src/engine/pdf/pdf_facade.dart +++ b/lib/src/engine/pdf/pdf_facade.dart @@ -39,6 +39,7 @@ final class PdfViewportConfiguration { required this.onPageChanged, this.onPositionChanged, this.onVisiblePagesChanged, + this.onContentReadyChanged, }); final int pageIndex; @@ -49,6 +50,7 @@ final class PdfViewportConfiguration { final ValueChanged onPageChanged; final void Function(int pageIndex, double pageOffset)? onPositionChanged; final ValueChanged>? onVisiblePagesChanged; + final ValueChanged? onContentReadyChanged; } abstract interface class PdfFacade { @@ -274,6 +276,12 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { _viewKey = key; _viewerRevision++; _restoring = true; + final revision = _viewerRevision; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_disposed && _restoring && revision == _viewerRevision) { + _configuration?.onContentReadyChanged?.call(false); + } + }); } final revision = _viewerRevision; return applyPdfBrightness( @@ -376,17 +384,12 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { 1.0, ); final visible = _controller.visibleRect; - final exposed = []; - for (var i = 0; i < _controller.layout.pageLayouts.length; i++) { - final rect = _controller.layout.pageLayouts[i]; - if (!rect.overlaps(visible)) continue; - final intersection = rect.intersect(visible); - if (intersection.width * intersection.height >= - rect.width * rect.height * .25) { - exposed.add(i); - } - } - config?.onVisiblePagesChanged?.call(exposed); + final exposure = pdfViewportExposure( + _controller.layout.pageLayouts, + visible, + ); + config?.onVisiblePagesChanged?.call(exposure.pages); + config?.onContentReadyChanged?.call(exposure.contentVisible); _pageIndex = index; _pageOffset = offset; if (config?.onPositionChanged != null) { @@ -408,6 +411,25 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { } } +/// Visible content permits time tracking even when zoom prevents page qualification. +({bool contentVisible, List pages}) pdfViewportExposure( + List pages, + Rect viewport, +) { + var contentVisible = false; + final qualified = []; + for (var i = 0; i < pages.length; i++) { + final rect = pages[i]; + if (!rect.overlaps(viewport)) continue; + final intersection = rect.intersect(viewport); + final area = intersection.width * intersection.height; + if (area <= 0) continue; + contentVisible = true; + if (area >= rect.width * rect.height * .25) qualified.add(i); + } + return (contentVisible: contentVisible, pages: qualified); +} + PdfViewerParams buildPdfViewerParams( PdfViewportConfiguration configuration, { PdfViewerReadyCallback? onViewerReady, diff --git a/lib/src/engine/pdf/pdf_reader_engine.dart b/lib/src/engine/pdf/pdf_reader_engine.dart index 2d37700..77eacfe 100644 --- a/lib/src/engine/pdf/pdf_reader_engine.dart +++ b/lib/src/engine/pdf/pdf_reader_engine.dart @@ -60,7 +60,7 @@ final class PdfReaderEngine extends ReaderEngine { } _exposedPages = const []; - _customViewportReady = false; + _viewportReady = null; PdfFacade? candidate; try { final bytes = await document.loadBytes(); @@ -180,6 +180,7 @@ final class PdfReaderEngine extends ReaderEngine { ); } _exposedPages = const []; + _viewportReady = null; _publish(); await facade.showPage(locator.pageIndex, locator.pageOffset); _locator = locator; @@ -286,7 +287,13 @@ final class PdfReaderEngine extends ReaderEngine { } List _exposedPages = const []; - bool _customViewportReady = false; + bool? _viewportReady; + void _contentReadyChanged(bool ready) { + if (_disposed || _viewportReady == ready) return; + _viewportReady = ready; + _publish(); + } + void _visiblePagesChanged(List pages) { if (_disposed) return; if (pages.length == _exposedPages.length && @@ -304,7 +311,7 @@ final class PdfReaderEngine extends ReaderEngine { if (_disposed) return; snapshot = ReaderReadySnapshot( document: _document!, - contentReady: _exposedPages.isNotEmpty || _customViewportReady, + contentReady: _viewportReady ?? _exposedPages.isNotEmpty, coverage: [ for (final page in _exposedPages) ReaderContentCoverage( @@ -342,10 +349,14 @@ final class PdfReaderEngine extends ReaderEngine { ReaderColumnMode.automatic => constraints.maxWidth >= 1000, }; _facingPages = facingPages; - if (_facadeFactory != null && !_customViewportReady) { + if (_facadeFactory != null && _viewportReady == null) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_disposed || !identical(_facade, facade)) return; - _customViewportReady = true; + if (_disposed || + !identical(_facade, facade) || + _viewportReady != null) { + return; + } + _viewportReady = true; _publish(); }); } @@ -359,6 +370,7 @@ final class PdfReaderEngine extends ReaderEngine { onPageChanged: _visiblePageChanged, onPositionChanged: _visiblePositionChanged, onVisiblePagesChanged: _visiblePagesChanged, + onContentReadyChanged: _contentReadyChanged, ), ); }, diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index c5d42f6..0cbfa25 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import '../controller/reader_controller.dart'; @@ -89,6 +90,9 @@ final class _PapyrusReaderState extends State { debugLabel: 'reader controls toggle', ); + ReaderActivityEvent? _lastActivity; + bool _activityReportScheduled = false; + bool get _isCommandBusy => _pendingCommands > 0; @override @@ -274,26 +278,38 @@ final class _PapyrusReaderState extends State { _reportActivity(); } + void _scheduleActivityReport() { + if (_activityReportScheduled) return; + _activityReportScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _activityReportScheduled = false; + _reportActivity(); + }); + } + void _reportActivity() { if (!mounted) return; + if (WidgetsBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + _scheduleActivityReport(); + return; + } final snapshot = _controller.snapshot; - _invokeHostCallback( - () => widget.onActivity?.call( - ReaderActivityEvent( - ready: snapshot.contentReady, - visible: - snapshot.contentReady && - _panel == _ReaderPanel.none && - _compactPanelRoute == null && - (ModalRoute.of(context)?.isCurrent ?? true), - cause: _controller.navigationCause, - locator: snapshot.locator, - coverage: snapshot.coverage, - atEnd: snapshot.atEnd, - ), - ), - 'onActivity', + final event = ReaderActivityEvent( + ready: snapshot.contentReady, + visible: + snapshot.contentReady && + _panel == _ReaderPanel.none && + _compactPanelRoute == null && + (ModalRoute.of(context)?.isCurrent ?? true), + cause: _controller.navigationCause, + locator: snapshot.locator, + coverage: snapshot.coverage, + atEnd: snapshot.atEnd, ); + if (event == _lastActivity || widget.onActivity == null) return; + _lastActivity = event; + _invokeHostCallback(() => widget.onActivity!(event), 'onActivity'); } void _invokeHostCallback(VoidCallback callback, String name) { @@ -328,7 +344,7 @@ final class _PapyrusReaderState extends State { @override Widget build(BuildContext context) { - WidgetsBinding.instance.addPostFrameCallback((_) => _reportActivity()); + _scheduleActivityReport(); final snapshot = _controller.snapshot; final ambient = Theme.of(context); final readerTheme = snapshot.status == ReaderStatus.ready diff --git a/test/controller/reader_controller_test.dart b/test/controller/reader_controller_test.dart index 4d6f4e3..2192767 100644 --- a/test/controller/reader_controller_test.dart +++ b/test/controller/reader_controller_test.dart @@ -135,6 +135,43 @@ void main() { expect(notifications, 1); }); + test( + 'command causes are scoped and subsequent engine navigation is a viewport change', + () async { + await controller.load(document); + final jump = EpubReaderLocator( + cfi: 'epubcfi(/6/4)', + spineIndex: 1, + localProgression: 0, + totalProgression: .2, + ); + final viewport = EpubReaderLocator( + cfi: 'epubcfi(/6/4)', + spineIndex: 1, + localProgression: .5, + totalProgression: .3, + ); + final observed = []; + controller.addListener(() => observed.add(controller.navigationCause)); + engine.locatorOnNavigation = jump; + engine.navigationGate = Completer(); + final command = controller.goTo(jump); + expect(observed.last, ReaderNavigationCause.jump); + engine.navigationGate!.complete(); + await command; + engine.emitLocator(viewport); + expect(observed.last, ReaderNavigationCause.viewport); + await controller.goNext(); + engine.emitLocator(jump); + expect(observed.last, ReaderNavigationCause.viewport); + await controller.updatePreferences( + const ReaderPreferences(fontSize: 20), + ); + engine.emitLocator(viewport); + expect(observed.last, ReaderNavigationCause.viewport); + }, + ); + test('delegates navigation and current location', () async { await controller.load(document); final locator = EpubReaderLocator( diff --git a/test/domain/reader_activity_test.dart b/test/domain/reader_activity_test.dart new file mode 100644 index 0000000..13c6bd2 --- /dev/null +++ b/test/domain/reader_activity_test.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +void main() { + final epub = EpubReaderLocator( + cfi: 'epubcfi(/6/2)', + spineIndex: 0, + localProgression: .5, + totalProgression: .25, + textOffset: 50, + ); + final pdf = PdfReaderLocator( + pageIndex: 2, + pageOffset: .1, + totalProgression: .4, + ); + for (final locator in [epub, pdf]) { + test( + 'activity round-trips version 1 ${locator.runtimeType} locators and coverage', + () { + final source = [ + if (locator is EpubReaderLocator) + const ReaderContentCoverage( + key: 'chapter:0', + start: .25, + end: .5, + chapterCount: 2, + ) + else + const ReaderContentCoverage( + key: 'pdf:2', + start: 0, + end: 1, + pdfPageIndex: 2, + ), + ]; + final event = ReaderActivityEvent( + ready: true, + visible: true, + cause: ReaderNavigationCause.viewport, + locator: locator, + coverage: source, + atEnd: true, + ); + source.clear(); + final json = + jsonDecode(jsonEncode(event.toJson())) as Map; + final restored = ReaderActivityEvent.fromJson(json); + expect(restored, event); + expect(restored.hashCode, event.hashCode); + expect(restored.locator, ReaderLocator.fromJson(locator.toJson())); + expect(restored.coverage, hasLength(1)); + expect(() => restored.coverage.clear(), throwsUnsupportedError); + }, + ); + } + test('activity rejects malformed and unsupported representations', () { + final valid = ReaderActivityEvent( + ready: true, + visible: true, + cause: ReaderNavigationCause.restore, + ).toJson(); + for (final patch in >[ + {'version': 2}, + {'ready': 1}, + {'visible': null}, + {'atEnd': 'true'}, + {'cause': 'unknown'}, + {'locator': 7}, + { + 'coverage': [7], + }, + { + 'locator': {...pdf.toJson(), 'version': 2}, + }, + ]) { + expect( + () => ReaderActivityEvent.fromJson({...valid, ...patch}), + throwsFormatException, + ); + } + }); + test( + 'coverage validates versions, normalized extents and calibration identity', + () { + final valid = const ReaderContentCoverage( + key: 'chapter:0', + start: 0, + end: 1, + ).toJson(); + for (final patch in >[ + {'version': 2}, + {'key': ''}, + {'start': -1}, + {'end': 2}, + {'start': .75, 'end': .5}, + {'start': double.nan}, + {'chapterCount': 0}, + {'pdfPageIndex': -1}, + {'chapterCount': 'two'}, + ]) { + expect( + () => ReaderContentCoverage.fromJson({...valid, ...patch}), + throwsFormatException, + ); + } + }, + ); +} diff --git a/test/domain/reader_snapshot_test.dart b/test/domain/reader_snapshot_test.dart index a9e0614..42f6706 100644 --- a/test/domain/reader_snapshot_test.dart +++ b/test/domain/reader_snapshot_test.dart @@ -32,6 +32,25 @@ void main() { expect(() => snapshot.toc.add(entry), throwsUnsupportedError); }); + test('ready snapshots defensively copy content coverage', () { + const coverage = ReaderContentCoverage( + key: 'chapter:0', + start: 0, + end: .5, + chapterCount: 2, + ); + final source = [coverage]; + final snapshot = ReaderReadySnapshot( + document: document, + preferences: const ReaderPreferences(), + capabilities: const ReaderCapabilities(), + coverage: source, + ); + source.clear(); + expect(snapshot.coverage, [coverage]); + expect(() => snapshot.coverage.clear(), throwsUnsupportedError); + }); + test('ReaderTocEntry defensively copies nested child source lists', () { final child = ReaderTocEntry(title: 'Section', locator: locator); final source = [child]; diff --git a/test/engine/pdf_reader_engine_test.dart b/test/engine/pdf_reader_engine_test.dart index 774a27f..d1ad231 100644 --- a/test/engine/pdf_reader_engine_test.dart +++ b/test/engine/pdf_reader_engine_test.dart @@ -16,6 +16,55 @@ void main() { } group('PdfReaderEngine', () { + test('zoomed content is ready without qualifying a full page', () { + final exposure = pdfViewportExposure([ + const Rect.fromLTWH(0, 0, 1000, 1000), + ], const Rect.fromLTWH(100, 100, 200, 200)); + expect(exposure.contentVisible, isTrue); + expect(exposure.pages, isEmpty); + final spread = pdfViewportExposure([ + const Rect.fromLTWH(0, 0, 500, 800), + const Rect.fromLTWH(510, 0, 500, 800), + ], const Rect.fromLTWH(0, 0, 1010, 800)); + expect(spread.pages, [0, 1]); + expect( + pdfViewportExposure([ + const Rect.fromLTWH(0, 0, 100, 100), + ], const Rect.fromLTWH(200, 200, 100, 100)).contentVisible, + isFalse, + ); + }); + + testWidgets( + 'viewport readiness is independent of qualified page coverage', + (tester) async { + final facade = FakePdfFacade(pageCount: 3); + final engine = PdfReaderEngine(facadeFactory: (_) async => facade); + addTearDown(engine.dispose); + await engine.load(document(), preferences: const ReaderPreferences()); + expect(engine.snapshot.contentReady, isFalse); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder(builder: engine.buildViewport), + ), + ); + final config = facade.lastConfiguration!; + config.onContentReadyChanged!(false); + config.onVisiblePagesChanged!([]); + expect(engine.snapshot.contentReady, isFalse); + config.onContentReadyChanged!(true); + expect(engine.snapshot.contentReady, isTrue); + expect(engine.snapshot.coverage, isEmpty); + config.onVisiblePagesChanged!([0, 1]); + expect(engine.snapshot.coverage.map((c) => c.pdfPageIndex), [0, 1]); + config.onVisiblePagesChanged!([]); + expect(engine.snapshot.contentReady, isTrue); + await engine.goNext(); + expect(engine.snapshot.contentReady, isFalse); + }, + ); + testWidgets('turns whole spreads only in paginated mode', (tester) async { tester.view.physicalSize = const Size(1200, 800); tester.view.devicePixelRatio = 1; diff --git a/test/presentation/papyrus_reader_test.dart b/test/presentation/papyrus_reader_test.dart index bf6f8d6..76dd016 100644 --- a/test/presentation/papyrus_reader_test.dart +++ b/test/presentation/papyrus_reader_test.dart @@ -138,6 +138,49 @@ void main() { expect(tester.state(viewport), same(state)); }); + testWidgets( + 'a host rebuilding on activity does not create a frame feedback loop', + (tester) async { + final events = []; + final controller = controllerFor([_UiReaderEngine()]); + final book = document('Host rebuild'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, rebuild) { + return PapyrusReader( + document: book, + controller: controller, + onActivity: (event) { + events.add(event); + rebuild(() {}); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle( + const Duration(milliseconds: 50), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + final count = events.length; + expect(events.last.ready, isTrue); + expect(count, lessThan(5)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(events.length, count); + await controller.goToProgress(.5); + await tester.pumpAndSettle(); + expect(events.length, count + 1); + expect(events.last.cause, ReaderNavigationCause.jump); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'generic activity pauses behind panels and observes jumps without page metrics', (tester) async { diff --git a/test/support/fake_reader_engine.dart b/test/support/fake_reader_engine.dart index 45e0d74..91f000d 100644 --- a/test/support/fake_reader_engine.dart +++ b/test/support/fake_reader_engine.dart @@ -36,6 +36,8 @@ class FakeReaderEngine extends ReaderEngine { final Map> _loadStartsByDocument = {}; ReaderLocator? currentLocatorResult; final List goToCalls = []; + ReaderLocator? locatorOnNavigation; + Completer? navigationGate; final List goToProgressCalls = []; int goNextCallCount = 0; int goPreviousCallCount = 0; @@ -93,6 +95,9 @@ class FakeReaderEngine extends ReaderEngine { @override Future goTo(ReaderLocator locator) async { goToCalls.add(locator); + final next = locatorOnNavigation; + if (next != null) emitLocator(next); + await navigationGate?.future; } @override