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 {