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..0ed94b5 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,8 @@ 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; @@ -57,6 +60,8 @@ final class ReaderController extends ChangeNotifier { candidate.dispose(); } _loadingEngines.clear(); + _navigationCommand = null; + navigationCause = ReaderNavigationCause.restore; final request = _ReaderLoadRequest( id: ++_latestLoadId, document: document, @@ -126,7 +131,10 @@ final class ReaderController extends ChangeNotifier { } Future goTo(ReaderLocator locator) async { - await _requireReadyEngine().goTo(locator); + await _navigate( + ReaderNavigationCause.jump, + (engine) => engine.goTo(locator), + ); } Future goToProgress(double progress) async { @@ -138,15 +146,21 @@ final class ReaderController extends ChangeNotifier { ); } - await _requireReadyEngine().goToProgress(progress); + await _navigate( + ReaderNavigationCause.jump, + (engine) => engine.goToProgress(progress), + ); } Future goNext() async { - await _requireReadyEngine().goNext(); + await _navigate(ReaderNavigationCause.turn, (engine) => engine.goNext()); } Future goPrevious() async { - await _requireReadyEngine().goPrevious(); + await _navigate( + ReaderNavigationCause.turn, + (engine) => engine.goPrevious(), + ); } Future currentLocator() async { @@ -154,8 +168,27 @@ final class ReaderController extends ChangeNotifier { } Future updatePreferences(ReaderPreferences preferences) async { - 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) { @@ -191,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 new file mode 100644 index 0000000..6302b1c --- /dev/null +++ b/lib/src/domain/reader_activity.dart @@ -0,0 +1,184 @@ +import 'package:flutter/foundation.dart'; + +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, + }); + + 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 { + ReaderActivityEvent({ + required this.ready, + required this.visible, + required this.cause, + this.locator, + 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 0c45750..5550450 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,21 @@ final class ReaderReadySnapshot extends ReaderSnapshot { this.locator, List toc = const [], this.locationLabel, - }) : toc = List.unmodifiable(toc); + List coverage = const [], + this.contentReady = true, + this.atEnd = false, + }) : toc = List.unmodifiable(toc), + coverage = List.unmodifiable(coverage); @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..22b079e 100644 --- a/lib/src/engine/pdf/pdf_facade.dart +++ b/lib/src/engine/pdf/pdf_facade.dart @@ -38,6 +38,8 @@ final class PdfViewportConfiguration { required this.layoutMode, required this.onPageChanged, this.onPositionChanged, + this.onVisiblePagesChanged, + this.onContentReadyChanged, }); final int pageIndex; @@ -47,6 +49,8 @@ final class PdfViewportConfiguration { final ReaderLayoutMode layoutMode; final ValueChanged onPageChanged; final void Function(int pageIndex, double pageOffset)? onPositionChanged; + final ValueChanged>? onVisiblePagesChanged; + final ValueChanged? onContentReadyChanged; } abstract interface class PdfFacade { @@ -272,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( @@ -373,6 +383,13 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { 0.0, 1.0, ); + final visible = _controller.visibleRect; + 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) { @@ -394,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 8667dd4..77eacfe 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 []; + _viewportReady = null; PdfFacade? candidate; try { final bytes = await document.loadBytes(); @@ -176,6 +179,9 @@ final class PdfReaderEngine extends ReaderEngine { 'The locator is not valid for the current PDF.', ); } + _exposedPages = const []; + _viewportReady = null; + _publish(); await facade.showPage(locator.pageIndex, locator.pageOffset); _locator = locator; _publish(); @@ -280,10 +286,42 @@ final class PdfReaderEngine extends ReaderEngine { _publish(); } + List _exposedPages = const []; + 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 && + 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: _viewportReady ?? _exposedPages.isNotEmpty, + 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 +349,17 @@ final class PdfReaderEngine extends ReaderEngine { ReaderColumnMode.automatic => constraints.maxWidth >= 1000, }; _facingPages = facingPages; + if (_facadeFactory != null && _viewportReady == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_disposed || + !identical(_facade, facade) || + _viewportReady != null) { + return; + } + _viewportReady = true; + _publish(); + }); + } return facade.buildViewport( PdfViewportConfiguration( pageIndex: locator.pageIndex, @@ -320,6 +369,8 @@ final class PdfReaderEngine extends ReaderEngine { layoutMode: _preferences.layoutMode, 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 fadf87e..0cbfa25 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -3,10 +3,12 @@ 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'; 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 +32,7 @@ final class PapyrusReader extends StatefulWidget { this.theme, this.controller, this.registry, + this.onActivity, this.onLocatorChanged, this.onPreferencesChanged, this.onBack, @@ -46,6 +49,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; @@ -86,6 +90,9 @@ final class _PapyrusReaderState extends State { debugLabel: 'reader controls toggle', ); + ReaderActivityEvent? _lastActivity; + bool _activityReportScheduled = false; + bool get _isCommandBusy => _pendingCommands > 0; @override @@ -268,6 +275,41 @@ final class _PapyrusReaderState extends State { } setState(() {}); + _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; + 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) { @@ -302,6 +344,7 @@ final class _PapyrusReaderState extends State { @override Widget build(BuildContext context) { + _scheduleActivityReport(); final snapshot = _controller.snapshot; final ambient = Theme.of(context); final readerTheme = snapshot.status == ReaderStatus.ready @@ -855,6 +898,7 @@ final class _PapyrusReaderState extends State { ); _compactPanelRoute = route; _compactPanelNavigator = navigator; + _reportActivity(); final sheet = navigator.push(route); unawaited( sheet.then( @@ -862,12 +906,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/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 d727bbd..76dd016 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,79 @@ 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 { + 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 { 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