diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 822aa23..dbbb98a 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -229,7 +229,11 @@ class AppRouter { return NoTransitionPage( key: state.pageKey, - child: ReaderPage(bookId: bookId), + child: ReaderPage( + key: ValueKey(state.uri.toString()), + bookId: bookId, + location: state.uri.queryParameters['location'], + ), ); }, ), diff --git a/app/lib/models/annotation.dart b/app/lib/models/annotation.dart index af1c409..4301fbe 100644 --- a/app/lib/models/annotation.dart +++ b/app/lib/models/annotation.dart @@ -65,11 +65,24 @@ class BookLocation { final String? chapterTitle; final int pageNumber; final double? percentage; + final Map? readerLocator; + final Map? selection; - const BookLocation({this.chapter, this.chapterTitle, required this.pageNumber, this.percentage}); + const BookLocation({ + this.chapter, + this.chapterTitle, + required this.pageNumber, + this.percentage, + this.readerLocator, + this.selection, + }); /// Get a display-friendly location string. String get displayLocation { + if (readerLocator?['type'] == 'epub') { + return '${chapterTitle ?? 'Chapter $chapter'} · ${((percentage ?? 0) * 100).round()}%'; + } + if (chapterTitle != null && chapter != null) { return 'Chapter $chapter: $chapterTitle, Page $pageNumber'; } @@ -83,6 +96,10 @@ class BookLocation { /// Get a short location string. String get shortLocation { + if (readerLocator?['type'] == 'epub') { + return 'Ch. $chapter · ${((percentage ?? 0) * 100).round()}%'; + } + if (chapter != null) { return 'Ch. $chapter, p. $pageNumber'; } @@ -90,12 +107,21 @@ class BookLocation { return 'Page $pageNumber'; } - BookLocation copyWith({int? chapter, String? chapterTitle, int? pageNumber, double? percentage}) { + BookLocation copyWith({ + int? chapter, + String? chapterTitle, + int? pageNumber, + double? percentage, + Map? readerLocator, + Map? selection, + }) { return BookLocation( chapter: chapter ?? this.chapter, chapterTitle: chapterTitle ?? this.chapterTitle, pageNumber: pageNumber ?? this.pageNumber, percentage: percentage ?? this.percentage, + readerLocator: readerLocator ?? this.readerLocator, + selection: selection ?? this.selection, ); } } @@ -157,6 +183,8 @@ class Annotation { 'chapter_title': location.chapterTitle, 'page_number': location.pageNumber, 'percentage': location.percentage, + 'reader_locator': location.readerLocator, + 'selection': location.selection, 'note': note, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt?.toIso8601String(), @@ -174,6 +202,8 @@ class Annotation { chapterTitle: json['chapter_title'] as String?, pageNumber: json['page_number'] as int, percentage: (json['percentage'] as num?)?.toDouble(), + readerLocator: (json['reader_locator'] as Map?)?.cast(), + selection: (json['selection'] as Map?)?.cast(), ), note: json['note'] as String?, createdAt: DateTime.parse(json['created_at'] as String), diff --git a/app/lib/models/bookmark.dart b/app/lib/models/bookmark.dart index 4103ad2..92a36f0 100644 --- a/app/lib/models/bookmark.dart +++ b/app/lib/models/bookmark.dart @@ -9,6 +9,7 @@ const _sentinel = _Sentinel(); class Bookmark { final String id; final String bookId; + final Map? readerLocator; final double position; // 0.0 to 1.0 final int? pageNumber; final String? chapterTitle; @@ -20,6 +21,7 @@ class Bookmark { required this.id, required this.bookId, required this.position, + this.readerLocator, this.pageNumber, this.chapterTitle, this.note, @@ -38,6 +40,10 @@ class Bookmark { /// Get a display-friendly location string. String get displayLocation { + if (readerLocator?['type'] == 'epub' && chapterTitle != null) { + return '$chapterTitle · ${(position * 100).round()}%'; + } + if (chapterTitle != null && pageNumber != null) { return '$chapterTitle, Page $pageNumber'; } @@ -61,6 +67,7 @@ class Bookmark { String? id, String? bookId, double? position, + Object? readerLocator = _sentinel, Object? pageNumber = _sentinel, Object? chapterTitle = _sentinel, Object? note = _sentinel, @@ -71,6 +78,7 @@ class Bookmark { id: id ?? this.id, bookId: bookId ?? this.bookId, position: position ?? this.position, + readerLocator: identical(readerLocator, _sentinel) ? this.readerLocator : readerLocator as Map?, pageNumber: identical(pageNumber, _sentinel) ? this.pageNumber : pageNumber as int?, chapterTitle: identical(chapterTitle, _sentinel) ? this.chapterTitle : chapterTitle as String?, note: identical(note, _sentinel) ? this.note : note as String?, @@ -84,6 +92,7 @@ class Bookmark { 'id': id, 'book_id': bookId, 'position': position, + 'reader_locator': readerLocator, 'page_number': pageNumber, 'chapter_title': chapterTitle, 'note': note, @@ -97,6 +106,7 @@ class Bookmark { id: json['id'] as String, bookId: json['book_id'] as String, position: (json['position'] as num).toDouble(), + readerLocator: (json['reader_locator'] as Map?)?.cast(), pageNumber: json['page_number'] as int?, chapterTitle: json['chapter_title'] as String?, note: json['note'] as String?, diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index 2ea044d..e4c1d37 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -1,6 +1,6 @@ import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:papyrus/reader/reader_navigation.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/annotation.dart'; @@ -300,7 +300,12 @@ class _AnnotationsPageState extends State { AnnotationCard( annotation: annotation, showActionMenu: isDesktop, - onTap: () => _navigateToBook(context, annotation.bookId), + onTap: () => openSavedReadingLocation( + context, + bookId: annotation.bookId, + anchor: annotation.location.readerLocator, + pageNumber: annotation.location.pageNumber, + ), onLongPress: () => _onAnnotationActions(context, provider, annotation), ), ); @@ -314,10 +319,6 @@ class _AnnotationsPageState extends State { ); } - void _navigateToBook(BuildContext context, String bookId) { - context.goNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); - } - void _onAnnotationActions(BuildContext context, AnnotationsProvider provider, Annotation annotation) async { final repository = context.read().libraryRepository?.annotations; final action = await AnnotationActionSheet.show(context, annotation: annotation); diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 18f5302..21b8d27 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; +import 'package:papyrus_reader/papyrus_reader.dart'; import 'dart:async'; import 'dart:typed_data'; @@ -56,6 +58,7 @@ class _BookDetailsPageState extends State with TickerProviderSt late TabController _tabController; bool _isPreparingReader = false; String? _readingError; + ReaderLocator? _readingTarget; @override void initState() { @@ -243,7 +246,7 @@ class _BookDetailsPageState extends State with TickerProviderSt onEdit: _onEdit, readingActionState: readingActionState, readingError: _readingError, - onRetryReading: _onContinueReading, + onRetryReading: () => _onContinueReading(locator: _readingTarget), ), const SizedBox(height: Spacing.md), @@ -305,7 +308,7 @@ class _BookDetailsPageState extends State with TickerProviderSt onEdit: _onEdit, readingActionState: readingActionState, readingError: _readingError, - onRetryReading: _onContinueReading, + onRetryReading: () => _onContinueReading(locator: _readingTarget), ), rail: _buildTabRail(provider), body: TabBarView( @@ -322,14 +325,21 @@ class _BookDetailsPageState extends State with TickerProviderSt isPhysical: provider.book!.isPhysical, onAddBookmark: _onAddBookmark, onBookmarkActions: _onBookmarkActions, + onBookmarkTap: provider.book!.isPhysical ? null : _onBookmarkTap, ), BookAnnotations( annotations: provider.annotations, isPhysical: provider.book!.isPhysical, onAddAnnotation: _onAddAnnotation, onAnnotationActions: _onAnnotationActions, + onAnnotationTap: provider.book!.isPhysical ? null : _onAnnotationTap, + ), + BookNotes( + notes: provider.notes, + onAddNote: _onAddNote, + onNoteTap: _onEditNote, + onNoteActions: _onNoteActions, ), - BookNotes(notes: provider.notes, onAddNote: _onAddNote, onNoteActions: _onNoteActions), ], ), ), @@ -352,6 +362,7 @@ class _BookDetailsPageState extends State with TickerProviderSt isPhysical: provider.book!.isPhysical, onAddBookmark: _onAddBookmark, onBookmarkActions: _onBookmarkActions, + onBookmarkTap: provider.book!.isPhysical ? null : _onBookmarkTap, ); case BookDetailsTab.annotations: return BookAnnotations( @@ -359,9 +370,15 @@ class _BookDetailsPageState extends State with TickerProviderSt isPhysical: provider.book!.isPhysical, onAddAnnotation: _onAddAnnotation, onAnnotationActions: _onAnnotationActions, + onAnnotationTap: provider.book!.isPhysical ? null : _onAnnotationTap, ); case BookDetailsTab.notes: - return BookNotes(notes: provider.notes, onAddNote: _onAddNote, onNoteActions: _onNoteActions); + return BookNotes( + notes: provider.notes, + onAddNote: _onAddNote, + onNoteTap: _onEditNote, + onNoteActions: _onNoteActions, + ); } } @@ -407,13 +424,38 @@ class _BookDetailsPageState extends State with TickerProviderSt ); } - Future _onContinueReading() async { + void _onBookmarkTap(Bookmark bookmark) => _openSavedLocation(bookmark.readerLocator, bookmark.pageNumber); + + void _onAnnotationTap(Annotation annotation) => + _openSavedLocation(annotation.location.readerLocator, annotation.location.pageNumber); + + void _openSavedLocation(Map? anchor, int? pageNumber) { + final book = _provider.book; + + if (book == null) { + return; + } + + try { + final locator = ReaderBookAdapter.savedLocator(book, anchor: anchor, pageNumber: pageNumber); + _onContinueReading(locator: locator); + } on FormatException { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('This item does not have a valid reading location.')), + ); + } + } + + Future _onContinueReading({ReaderLocator? locator}) async { final book = _provider.book; if (book == null || _isPreparingReader) { return; } + _readingTarget = locator; + final dataStore = context.read(); + final repository = dataStore.requireBookRepository(); final messenger = ScaffoldMessenger.of(context); if (ReaderBookAdapter.formatFor(book.fileFormat) == null) { @@ -477,7 +519,16 @@ class _BookDetailsPageState extends State with TickerProviderSt return; } - await context.pushNamed('BOOK_READER', pathParameters: {'bookId': book.id}); + if (!dataStore.isBookRepositoryCurrent(repository)) { + setState(() => _readingError = 'Your library changed. Reopen this book from the library.'); + return; + } + + await context.pushNamed( + 'BOOK_READER', + pathParameters: {'bookId': book.id}, + queryParameters: {if (locator != null) 'location': jsonEncode(locator.toJson())}, + ); } BookReadingActionState _readingActionState(BookStorageStatusController? controller, Book book) { @@ -591,18 +642,11 @@ class _BookDetailsPageState extends State with TickerProviderSt final repository = context.read().libraryRepository?.notes; - final note = await NoteDialog.show( + await NoteDialog.show( context, bookId: _provider.book!.id, onSave: (note) => _provider.addNote(note, repository: repository), ); - - if (note != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - const SnackBar(content: Text('Note added')), - ); - } } void _onAddBookmark() async { @@ -612,19 +656,12 @@ class _BookDetailsPageState extends State with TickerProviderSt final repository = context.read().libraryRepository?.bookmarks; - final bookmark = await BookmarkDialog.show( + await BookmarkDialog.show( context, bookId: _provider.book!.id, pageCount: _provider.book!.pageCount, onSave: (bookmark) => _provider.addBookmark(bookmark, repository: repository), ); - - if (bookmark != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - const SnackBar(content: Text('Bookmark added')), - ); - } } void _onAddAnnotation() async { @@ -634,18 +671,11 @@ class _BookDetailsPageState extends State with TickerProviderSt final repository = context.read().libraryRepository?.annotations; - final annotation = await AnnotationDialog.show( + await AnnotationDialog.show( context, bookId: _provider.book!.id, onSave: (annotation) => _provider.addAnnotation(annotation, repository: repository), ); - - if (annotation != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - const SnackBar(content: Text('Annotation added')), - ); - } } void _onNoteActions(Note note) async { @@ -670,19 +700,12 @@ class _BookDetailsPageState extends State with TickerProviderSt final repository = context.read().libraryRepository?.notes; - final updatedNote = await NoteDialog.show( + await NoteDialog.show( context, bookId: _provider.book!.id, existingNote: note, onSave: (updated) => _provider.updateNote(note.id, updated, previous: note, repository: repository), ); - - if (updatedNote != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - const SnackBar(content: Text('Note updated')), - ); - } } void _onDeleteNote(Note note) async { @@ -702,15 +725,6 @@ class _BookDetailsPageState extends State with TickerProviderSt return; } - - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - const SnackBar(content: Text('Note deleted')), - ); } } diff --git a/app/lib/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index de2ee23..80e0a90 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -1,6 +1,6 @@ import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:papyrus/reader/reader_navigation.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/bookmark.dart'; @@ -312,7 +312,12 @@ class _BookmarksPageState extends State { bookmark: bookmark, bookTitle: provider.getBookTitle(bookmark.bookId), showActionMenu: isDesktop, - onTap: () => _navigateToBook(context, bookmark.bookId), + onTap: () => openSavedReadingLocation( + context, + bookId: bookmark.bookId, + anchor: bookmark.readerLocator, + pageNumber: bookmark.pageNumber, + ), onLongPress: () => _onBookmarkActions(context, provider, bookmark), ), ); @@ -326,10 +331,6 @@ class _BookmarksPageState extends State { ); } - void _navigateToBook(BuildContext context, String bookId) { - context.goNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); - } - void _onBookmarkActions(BuildContext context, BookmarksProvider provider, Bookmark bookmark) async { final repository = context.read().libraryRepository?.bookmarks; final action = await BookmarkActionSheet.show(context, bookmark: bookmark); diff --git a/app/lib/pages/notes_page.dart b/app/lib/pages/notes_page.dart index 17380a2..cd283f2 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -1,6 +1,5 @@ import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/providers/notes_provider.dart'; @@ -288,7 +287,7 @@ class _NotesPageState extends State { items.add( NoteCard( note: note, - onTap: () => _navigateToBook(context, note.bookId), + onTap: () => _editNote(context, provider, note), onLongPress: () => _showNoteActions(context, provider, note), ), ); @@ -302,8 +301,15 @@ class _NotesPageState extends State { ); } - void _navigateToBook(BuildContext context, String bookId) { - context.goNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); + Future _editNote(BuildContext context, NotesProvider provider, Note note) { + final repository = context.read().libraryRepository?.notes; + + return NoteDialog.show( + context, + bookId: note.bookId, + existingNote: note, + onSave: (updated) => provider.updateNote(updated, previous: note, repository: repository), + ); } void _showNoteActions(BuildContext context, NotesProvider provider, Note note) async { diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index 058ad24..47b9d10 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -13,7 +13,10 @@ import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/reader/reader_book_adapter.dart'; import 'package:papyrus/reader/reader_session.dart'; -import 'package:papyrus/reader/reader_panel_sheet.dart'; +import 'package:papyrus/reader/reader_panel_route.dart'; +import 'package:papyrus/reader/reader_library.dart'; +import 'package:papyrus/reader/reader_bookmark_button.dart'; +import 'package:papyrus/reader/reader_library_panel.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; @@ -21,15 +24,18 @@ import 'package:provider/provider.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class ReaderPage extends StatefulWidget { - const ReaderPage({super.key, required this.bookId}); + const ReaderPage({super.key, required this.bookId, this.location}); final String bookId; + final String? location; @override State createState() => _ReaderPageState(); } class _ReaderPageState extends State { + final _controller = ReaderController(registry: ReaderEngineRegistry.defaults()); + ReaderLibrary? _library; Book? _book; ReaderDocument? _document; ReaderLocator? _initialLocator; @@ -111,6 +117,17 @@ class _ReaderPageState extends State { return; } + final ReaderLocator? launchLocator; + + try { + launchLocator = widget.location == null + ? ReaderBookAdapter.restoreLocator(book) + : ReaderBookAdapter.decodeLocation(book, widget.location!); + } on FormatException { + setState(() => _error = 'This saved location could not be opened.'); + return; + } + try { final importService = context.read(); @@ -155,11 +172,9 @@ class _ReaderPageState extends State { } _tracker = tracker; - - if (tracker != null) { - _trackingStore = dataStore; - dataStore.addListener(_scopeChanged); - } + _library = ReaderLibrary(store: dataStore, bookId: book.id); + _trackingStore = dataStore; + dataStore.addListener(_scopeChanged); final session = ReaderSession( book: book, @@ -183,7 +198,7 @@ class _ReaderPageState extends State { loadBytes: () async => bytes, ); - _initialLocator = ReaderBookAdapter.restoreLocator(book); + _initialLocator = launchLocator; _initialPreferences = preferences; _session = session; }); @@ -196,7 +211,13 @@ class _ReaderPageState extends State { } } - void _scopeChanged() => _tracker?.updateScope(_trackingStore!.getShelfIdsForBook(widget.bookId)); + void _scopeChanged() { + _tracker?.updateScope(_trackingStore!.getShelfIdsForBook(widget.bookId)); + + if (mounted) { + setState(() {}); + } + } @override void dispose() { @@ -204,6 +225,7 @@ class _ReaderPageState extends State { _finishTimer?.cancel(); _lifecycle.dispose(); _session?.dispose(); + _controller.dispose(); super.dispose(); } @@ -234,21 +256,39 @@ class _ReaderPageState extends State { ); } - return ReaderSystemTextScope( - child: PapyrusReader( - document: document, - initialLocator: _initialLocator, - initialPreferences: preferences, - builders: ReaderUiBuilders( - loading: (context, state) => const _ReaderLoading(), - compactPanelRoute: (_, panel) => buildReaderPanelSheet(context, panel), + return Scaffold( + body: ReaderSystemTextScope( + child: PapyrusReader( + document: document, + controller: _controller, + theme: ReaderThemeData.fromTheme(Theme.of(context)).copyWith(sidePanelWidth: 420), + highlights: _library!.highlights, + onEditHighlight: (highlight) => _library!.editHighlight(context, highlight), + onRemoveHighlight: (highlight) => _library!.removeHighlight(context, highlight), + onAnnotate: (selection) => _library!.annotate(context, _controller.snapshot, selection), + initialLocator: _initialLocator, + initialPreferences: preferences, + builders: ReaderUiBuilders( + loading: (context, state) => const _ReaderLoading(), + tableOfContents: (context, panel) => ReaderLibraryPanel(library: _library!, panel: panel), + toolbarActions: (context, state) => [ + ReaderBookmarkButton(library: _library!, snapshot: state.snapshot, isBusy: state.isBusy), + IconButton( + tooltip: 'New note', + icon: const Icon(Icons.note_add_outlined), + onPressed: state.isBusy || !_library!.isCurrent ? null : () => _library!.addNote(context), + ), + ], + compactPanelRoute: (_, panel) => + buildReaderPanelRoute(context, panel, bookTitle: book.title, showTitle: false), + ), + onLocatorChanged: _session!.updateLocator, + onActivity: _onActivity, + onPreferencesChanged: (updated) { + ReaderBookAdapter.persistPreferences(context.read(), updated); + }, + onBack: _close, ), - onLocatorChanged: _session!.updateLocator, - onActivity: _onActivity, - onPreferencesChanged: (updated) { - ReaderBookAdapter.persistPreferences(context.read(), updated); - }, - onBack: _close, ), ); } diff --git a/app/lib/powersync/library_row_mapper.dart b/app/lib/powersync/library_row_mapper.dart index 3855617..a4c8f6b 100644 --- a/app/lib/powersync/library_row_mapper.dart +++ b/app/lib/powersync/library_row_mapper.dart @@ -51,7 +51,7 @@ final tagRowMapper = LibraryRowMapper( Map _locatedRow(Map json, BookLocation? location) { final row = Map.from(json); - for (final key in ['chapter', 'chapter_title', 'page_number', 'percentage']) { + for (final key in ['chapter', 'chapter_title', 'page_number', 'percentage', 'reader_locator', 'selection']) { row.remove(key); } @@ -62,6 +62,8 @@ Map _locatedRow(Map json, BookLocation? locati 'chapter_title': location.chapterTitle, 'page_number': location.pageNumber, 'percentage': location.percentage, + 'reader_locator': location.readerLocator, + 'selection': location.selection, }; return encodeLibraryRow(row); @@ -116,7 +118,7 @@ Map decodeLibraryRow(Map row) => row.map((key, return MapEntry(key, value == true || value == 1); } - if (['tags', 'location', 'payload'].contains(key) && value is String) { + if (['tags', 'location', 'payload', 'reader_locator'].contains(key) && value is String) { return MapEntry(key, jsonDecode(value)); } diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index b77dc0d..23f3591 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -94,6 +94,7 @@ const _annotationsColumns = [ ]; const _bookmarkColumns = [ + Column.text('reader_locator'), Column.text('owner_user_id'), Column.text('book_id'), Column.real('position'), diff --git a/app/lib/reader/reader_book_adapter.dart b/app/lib/reader/reader_book_adapter.dart index afb0ec1..5f08876 100644 --- a/app/lib/reader/reader_book_adapter.dart +++ b/app/lib/reader/reader_book_adapter.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; @@ -42,6 +43,46 @@ final class ReaderBookAdapter { } } + static ReaderLocator savedLocator(Book book, {Map? anchor, int? pageNumber}) { + final format = formatFor(book.fileFormat); + + if (anchor == null) { + if (format == ReaderFormat.pdf && pageNumber != null && pageNumber > 0) { + return PdfReaderLocator( + pageIndex: pageNumber - 1, + pageOffset: 0, + totalProgression: book.pageCount != null && book.pageCount! > 1 + ? ((pageNumber - 1) / (book.pageCount! - 1)).clamp(0, 1) + : 0, + ); + } + + throw const FormatException('This item does not have an exact reading location.'); + } + + final locator = ReaderLocator.fromJson(anchor); + final matchesFormat = switch (locator) { + EpubReaderLocator() => format == ReaderFormat.epub, + PdfReaderLocator() => format == ReaderFormat.pdf, + }; + + if (!matchesFormat) { + throw const FormatException('This saved location does not match the book format.'); + } + + return locator; + } + + static ReaderLocator decodeLocation(Book book, String location) { + final decoded = jsonDecode(location); + + if (decoded is! Map) { + throw const FormatException('Invalid reading location.'); + } + + return savedLocator(book, anchor: decoded); + } + static Book applyLocator(Book book, ReaderLocator locator, {required DateTime now}) { final metadata = Map.from(book.customMetadata ?? const {}); metadata[locatorMetadataKey] = locator.toJson(); diff --git a/app/lib/reader/reader_bookmark_button.dart b/app/lib/reader/reader_bookmark_button.dart new file mode 100644 index 0000000..77cfceb --- /dev/null +++ b/app/lib/reader/reader_bookmark_button.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/reader/reader_library.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +class ReaderBookmarkButton extends StatefulWidget { + const ReaderBookmarkButton({ + super.key, + required this.library, + required this.snapshot, + this.isBusy = false, + this.floating = false, + }); + + final ReaderLibrary library; + final ReaderSnapshot snapshot; + final bool isBusy; + final bool floating; + + @override + State createState() => _ReaderBookmarkButtonState(); +} + +class _ReaderBookmarkButtonState extends State { + String? _pendingLabel; + + Future _toggle() async { + if (_pendingLabel != null) { + return; + } + + final saved = widget.library.bookmarkAt(widget.snapshot) != null; + setState(() => _pendingLabel = saved ? 'Removing bookmark…' : 'Saving bookmark…'); + + try { + await widget.library.toggleBookmark(context, widget.snapshot); + } finally { + if (mounted) { + setState(() => _pendingLabel = null); + } + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: widget.library.store, + builder: (context, _) { + final saved = widget.library.bookmarkAt(widget.snapshot) != null; + final enabled = + _pendingLabel == null && !widget.isBusy && widget.library.isCurrent && widget.snapshot.locator != null; + final label = _pendingLabel ?? (saved ? 'Remove bookmark' : 'Add bookmark'); + final icon = Icon( + saved ? Icons.bookmark : Icons.bookmark_add_outlined, + color: saved ? Theme.of(context).colorScheme.primary : null, + ); + + return Semantics( + selected: saved, + liveRegion: true, + child: widget.floating + ? FloatingActionButton( + heroTag: null, + tooltip: saved || _pendingLabel != null ? label : 'Bookmark this location', + onPressed: enabled ? _toggle : null, + child: icon, + ) + : IconButton( + tooltip: label, + onPressed: enabled ? _toggle : null, + icon: icon, + ), + ); + }, + ); +} diff --git a/app/lib/reader/reader_library.dart b/app/lib/reader/reader_library.dart new file mode 100644 index 0000000..14c614f --- /dev/null +++ b/app/lib/reader/reader_library.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; +import 'package:papyrus/widgets/book_details/bookmark_dialog.dart'; +import 'package:papyrus/widgets/book_details/note_dialog.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:uuid/uuid.dart'; + +/// Captures the library that opened the book, including editors left open during a profile switch. +class ReaderLibrary { + ReaderLibrary({required this.store, required this.bookId}) + : books = store.requireBookRepository(), + bookmarks = store.libraryRepository?.bookmarks, + annotations = store.libraryRepository?.annotations, + notes = store.libraryRepository?.notes; + + final DataStore store; + final String bookId; + final BookRepository books; + final EntityRepository? bookmarks; + final EntityRepository? annotations; + final EntityRepository? notes; + bool _changingBookmark = false; + + bool get isCurrent => store.isBookRepositoryCurrent(books); + + void _requireCurrent() { + if (!isCurrent) { + throw StateError('Your library changed. Reopen this book from the library.'); + } + } + + List get highlights { + if (!isCurrent) { + return const []; + } + + final result = []; + + for (final annotation in store.getAnnotationsForBook(bookId)) { + final selection = annotation.location.selection; + + if (selection == null) { + continue; + } + + try { + result.add( + ReaderHighlight( + id: annotation.id, + selection: ReaderSelection.fromJson(selection), + color: annotation.color.accentColor, + ), + ); + } on Object { + // A malformed anchor must not prevent opening the book or viewing its annotation. + } + } + + return result; + } + + BookLocation location(ReaderSnapshot snapshot, {ReaderSelection? selection}) { + final locator = selection?.start ?? snapshot.locator!; + final progression = switch (locator) { + EpubReaderLocator() => locator.totalProgression, + PdfReaderLocator() => locator.totalProgression, + }; + String? chapterTitle; + EpubReaderLocator? chapterLocator; + + int comparePositions(EpubReaderLocator left, EpubReaderLocator right) { + if (left.textOffset != null && right.textOffset != null) { + return left.textOffset!.compareTo(right.textOffset!); + } + + return left.localProgression.compareTo(right.localProgression); + } + + void findTitle(List entries) { + for (final entry in entries) { + final entryLocator = entry.locator; + + if (locator is EpubReaderLocator && + entryLocator is EpubReaderLocator && + entryLocator.spineIndex == locator.spineIndex) { + final unresolvedAnchor = entryLocator.anchor != null && entryLocator.textOffset == null; + final precedesLocation = comparePositions(entryLocator, locator) <= 0; + final followsChapter = chapterLocator == null || comparePositions(entryLocator, chapterLocator!) >= 0; + + if (!unresolvedAnchor && precedesLocation && followsChapter) { + chapterTitle = entry.title; + chapterLocator = entryLocator; + } + } + + findTitle(entry.children); + } + } + + findTitle(snapshot.toc); + + return BookLocation( + chapter: locator is EpubReaderLocator ? locator.spineIndex + 1 : null, + chapterTitle: chapterTitle, + pageNumber: locator is PdfReaderLocator ? locator.pageIndex + 1 : 1, + percentage: progression, + readerLocator: locator.toJson(), + selection: selection?.toJson(), + ); + } + + Bookmark? bookmarkAt(ReaderSnapshot snapshot) { + if (!isCurrent || snapshot.locator == null) { + return null; + } + + final current = snapshot.locator!; + + for (final bookmark in store.getBookmarksForBook(bookId)) { + final anchor = bookmark.readerLocator; + + if (anchor == null) { + continue; + } + + final ReaderLocator saved; + + try { + saved = ReaderLocator.fromJson(anchor); + } on FormatException { + continue; + } + + if (saved is PdfReaderLocator && current is PdfReaderLocator) { + final pageIndex = saved.pageIndex; + + if (saved.pageIndex == current.pageIndex || + snapshot.coverage.any((coverage) => coverage.pdfPageIndex == pageIndex)) { + return bookmark; + } + } else if (saved is EpubReaderLocator && current is EpubReaderLocator && saved.spineIndex == current.spineIndex) { + if (saved.textOffset != null && saved.textOffset == current.textOffset) { + return bookmark; + } + + if (saved.textOffset == null && + current.textOffset == null && + saved.localProgression == current.localProgression) { + return bookmark; + } + + for (final coverage in snapshot.coverage) { + if (!coverage.key.startsWith('epub:${saved.spineIndex}:') || saved.textOffset == null) { + continue; + } + + final length = int.tryParse(coverage.key.split(':').last); + + if (length != null && length > 0) { + final position = saved.textOffset! / length; + + if (position >= coverage.start && position < coverage.end) { + return bookmark; + } + } + } + } + } + + return null; + } + + Future addBookmark(BuildContext context, ReaderSnapshot snapshot) async { + if (_changingBookmark || snapshot.locator == null || bookmarkAt(snapshot) != null) { + return; + } + + _changingBookmark = true; + + try { + _requireCurrent(); + final at = location(snapshot); + + await store.addBookmark( + Bookmark( + id: const Uuid().v4(), + bookId: bookId, + position: at.percentage!, + pageNumber: snapshot.locator is PdfReaderLocator ? at.pageNumber : null, + chapterTitle: at.chapterTitle, + readerLocator: at.readerLocator, + createdAt: DateTime.now(), + ), + repository: bookmarks, + ); + } on Object { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not save bookmark. Try again.'))); + } + } finally { + _changingBookmark = false; + } + } + + Future toggleBookmark(BuildContext context, ReaderSnapshot snapshot) async { + if (_changingBookmark || snapshot.locator == null) { + return; + } + + final bookmark = bookmarkAt(snapshot); + + if (bookmark == null) { + await addBookmark(context, snapshot); + return; + } + + _changingBookmark = true; + + try { + _requireCurrent(); + await store.deleteBookmark(bookmark.id, repository: bookmarks); + } on Object { + if (context.mounted && isCurrent) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(const SnackBar(content: Text('Could not remove bookmark. Try again.'))); + } + } finally { + _changingBookmark = false; + } + } + + Future editBookmark(BuildContext context, Bookmark bookmark) => BookmarkDialog.show( + context, + bookId: bookId, + existingBookmark: bookmark, + onSave: (updated) { + _requireCurrent(); + return store.updateBookmark(updated, previous: bookmark, repository: bookmarks); + }, + ); + + Future annotate(BuildContext context, ReaderSnapshot snapshot, ReaderSelection selection) => + AnnotationDialog.show( + context, + bookId: bookId, + initialText: selection.text, + initialLocation: location(snapshot, selection: selection), + onSave: (annotation) { + _requireCurrent(); + return store.addAnnotation(annotation, repository: annotations); + }, + ); + + Future editAnnotation(BuildContext context, Annotation annotation) => AnnotationDialog.show( + context, + bookId: bookId, + existingAnnotation: annotation, + onSave: (updated) { + _requireCurrent(); + return store.updateAnnotation(updated, previous: annotation, repository: annotations); + }, + ); + + Future editHighlight(BuildContext context, ReaderHighlight highlight) async { + if (!isCurrent) { + return; + } + + final annotation = store.getAnnotation(highlight.id); + + if (annotation != null && annotation.bookId == bookId) { + await editAnnotation(context, annotation); + } + } + + Future removeHighlight(BuildContext context, ReaderHighlight highlight) async { + if (!isCurrent) { + return; + } + + final annotation = store.getAnnotation(highlight.id); + + if (annotation == null || annotation.bookId != bookId) { + return; + } + + await confirmDelete(context, annotation); + } + + Future confirmDelete(BuildContext context, Object item) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(item is Annotation ? 'Remove annotation?' : 'Delete this item?'), + content: const Text('This cannot be undone.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(item is Annotation ? 'Remove' : 'Delete'), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + try { + await delete(item); + } on Object { + if (context.mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Could not delete item. Try again.'))); + } + } + } + + Future addNote(BuildContext context) => NoteDialog.show( + context, + bookId: bookId, + onSave: (note) { + _requireCurrent(); + return store.addNote(note, repository: notes); + }, + ); + + Future editNote(BuildContext context, Note note) => NoteDialog.show( + context, + bookId: bookId, + existingNote: note, + onSave: (updated) { + _requireCurrent(); + return store.updateNote(updated, previous: note, repository: notes); + }, + ); + + Future delete(Object item) { + _requireCurrent(); + + return switch (item) { + Bookmark() => store.deleteBookmark(item.id, repository: bookmarks), + Annotation() => store.deleteAnnotation(item.id, repository: annotations), + Note() => store.deleteNote(item.id, repository: notes), + _ => throw ArgumentError.value(item), + }; + } +} diff --git a/app/lib/reader/reader_library_panel.dart b/app/lib/reader/reader_library_panel.dart new file mode 100644 index 0000000..dcf051e --- /dev/null +++ b/app/lib/reader/reader_library_panel.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/reader/reader_library.dart'; +import 'package:papyrus/reader/reader_bookmark_button.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +class ReaderLibraryPanel extends StatelessWidget { + const ReaderLibraryPanel({super.key, required this.library, required this.panel}); + + final ReaderLibrary library; + final ReaderPanelContext panel; + + @override + Widget build(BuildContext context) => DefaultTabController( + length: 4, + animationDuration: AppMotion.duration(context, kTabScrollDuration), + child: ListenableBuilder( + listenable: library.store, + builder: (context, _) { + if (!library.isCurrent) { + return const Center(child: Text('Your library changed. Reopen this book from the library.')); + } + + final bookmarks = library.store.getBookmarksForBook(library.bookId) + ..sort((left, right) => left.position.compareTo(right.position)); + final annotations = library.store.getAnnotationsForBook(library.bookId) + ..sort((left, right) => (left.location.percentage ?? 0).compareTo(right.location.percentage ?? 0)); + final notes = library.store.getNotesForBook(library.bookId) + ..sort((left, right) => right.createdAt.compareTo(left.createdAt)); + + return Column( + children: [ + if (panel.showHeader) + ListTile( + title: const Text('Contents'), + trailing: IconButton(tooltip: 'Close contents', onPressed: panel.close, icon: const Icon(Icons.close)), + ), + const TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: [ + Tab(text: 'Chapters'), + Tab(text: 'Bookmarks'), + Tab(text: 'Annotations'), + Tab(text: 'Notes'), + ], + ), + Expanded( + child: TabBarView( + physics: AppMotion.disabled(context) ? const NeverScrollableScrollPhysics() : null, + children: [ + panel.buildContents!(context), + _list( + context, + items: bookmarks, + empty: 'Save a bookmark to return to this place later.', + action: ReaderBookmarkButton( + library: library, + snapshot: panel.snapshot, + isBusy: panel.isBusy, + floating: true, + ), + ), + _list(context, items: annotations, empty: 'Select text in the book and choose Annotate.'), + _list( + context, + items: notes, + empty: 'Keep your thoughts about this book here.', + action: FloatingActionButton( + heroTag: null, + tooltip: 'Add note', + onPressed: panel.isBusy ? null : () => library.addNote(context), + child: const Icon(Icons.note_add_outlined), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + + Widget _list(BuildContext context, {required List items, required String empty, Widget? action}) => Stack( + fit: StackFit.expand, + children: [ + if (items.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Text(empty, textAlign: TextAlign.center), + ), + ) + else + ListView.separated( + padding: EdgeInsets.only( + top: Spacing.sm, + bottom: action == null ? Spacing.sm : Spacing.md + Spacing.fabClearance, + ), + itemCount: items.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) => _entry(context, items[index]), + ), + + if (action != null) + PositionedDirectional( + end: Spacing.md, + bottom: Spacing.md, + child: SafeArea(top: false, left: false, right: false, child: action), + ), + ], + ); + + Widget _entry(BuildContext context, Object item) { + final (title, subtitle, icon, color, anchor) = switch (item) { + Bookmark() => ( + item.note ?? item.chapterTitle ?? 'Bookmark', + item.displayLocation, + Icons.bookmark, + item.color, + item.readerLocator, + ), + Annotation() => ( + item.selectedText, + [item.location.displayLocation, if (item.hasNote) item.note!].join('\n'), + Icons.format_quote, + item.color.accentColor, + item.location.readerLocator, + ), + Note() => ( + item.title, + item.preview, + Icons.notes, + Theme.of(context).colorScheme.primary, + null, + ), + _ => throw ArgumentError.value(item), + }; + + return ListTile( + leading: Icon(icon, color: color), + title: Text(title, maxLines: 3, overflow: TextOverflow.ellipsis), + subtitle: Text(subtitle, maxLines: 3, overflow: TextOverflow.ellipsis), + onTap: () => _view(context, item, title, anchor), + trailing: PopupMenuButton( + tooltip: 'Item actions', + onSelected: (action) { + if (action == 'delete') { + _delete(context, item); + } else { + _edit(context, item); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ); + } + + void _goTo(BuildContext context, Map anchor) { + if (!library.isCurrent || panel.isBusy) { + return; + } + + try { + panel.navigate?.call(ReaderLocator.fromJson(anchor)); + } on FormatException { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('This saved location could not be opened.'))); + } + } + + Future _view(BuildContext context, Object item, String title, Map? anchor) async { + if (item is Bookmark && anchor != null) { + _goTo(context, anchor); + return; + } + + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(switch (item) { + Note() => item.title, + Bookmark() => 'Bookmark', + _ => 'Annotation', + }), + scrollable: true, + content: SelectableText(switch (item) { + Note() => item.content, + Annotation() => [item.selectedText, if (item.hasNote) item.note!].join('\n\n'), + _ => title, + }), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Close')), + if (anchor != null) + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + _goTo(context, anchor); + }, + child: const Text('Go to passage'), + ), + ], + ), + ); + } + + void _edit(BuildContext context, Object item) { + switch (item) { + case Bookmark(): + library.editBookmark(context, item); + case Annotation(): + library.editAnnotation(context, item); + case Note(): + library.editNote(context, item); + } + } + + Future _delete(BuildContext context, Object item) => library.confirmDelete(context, item); +} diff --git a/app/lib/reader/reader_navigation.dart b/app/lib/reader/reader_navigation.dart new file mode 100644 index 0000000..ac4ee88 --- /dev/null +++ b/app/lib/reader/reader_navigation.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/reader/reader_book_adapter.dart'; +import 'package:provider/provider.dart'; + +Future openSavedReadingLocation( + BuildContext context, { + required String bookId, + Map? anchor, + int? pageNumber, +}) async { + final book = context.read().getBook(bookId); + + if (book == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('This book is no longer in your library.'))); + return; + } + + if (book.isPhysical) { + await context.pushNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); + return; + } + + final String location; + + try { + location = jsonEncode(ReaderBookAdapter.savedLocator(book, anchor: anchor, pageNumber: pageNumber).toJson()); + } on FormatException { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('This item does not have a valid reading location.')), + ); + return; + } + + await context.pushNamed( + 'BOOK_READER', + pathParameters: {'bookId': bookId}, + queryParameters: {'location': location}, + ); +} diff --git a/app/lib/reader/reader_panel_route.dart b/app/lib/reader/reader_panel_route.dart new file mode 100644 index 0000000..10736c5 --- /dev/null +++ b/app/lib/reader/reader_panel_route.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:papyrus/themes/app_accessibility.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; +import 'package:papyrus/widgets/shared/app_page_bar.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +/// The client owns panel presentation; the reader supplies live panel content. +Route buildReaderPanelRoute( + BuildContext context, + ReaderPanelRouteContext panel, { + String? bookTitle, + bool showTitle = true, +}) { + final navigator = Navigator.of(context); + final theme = Theme.of(context); + final localizations = MaterialLocalizations.of(context); + + if (panel.kind == ReaderPanelKind.tableOfContents) { + final capturedThemes = InheritedTheme.capture(from: context, to: navigator.context); + + return MaterialPageRoute( + builder: (context) => capturedThemes.wrap( + ReaderSystemTextScope( + child: Builder( + builder: (context) => CallbackShortcuts( + bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => Navigator.of(context).pop()}, + child: Focus( + autofocus: true, + child: Scaffold( + appBar: AppPageBar( + context: context, + leading: const BackButton(), + title: Text(bookTitle ?? panel.title, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + body: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showTitle) + Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Text(panel.title, style: Theme.of(context).textTheme.titleMedium), + ), + if (showTitle) const Divider(height: 1), + Expanded(child: panel.buildContent(context, null)), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + return ModalBottomSheetRoute( + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + barrierLabel: localizations.scrimLabel, + barrierOnTapHint: localizations.scrimOnTapHint(localizations.bottomSheetLabel), + modalBarrierColor: theme.bottomSheetTheme.modalBarrierColor, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: false, + sheetAnimationStyle: AppMotion.animationStyle(context), + shape: + theme.bottomSheetTheme.shape ?? + const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), + clipBehavior: Clip.antiAlias, + builder: (context) => ReaderSystemTextScope( + child: AppBottomSheet( + title: panel.title, + contentPadding: EdgeInsets.zero, + scrollBodyBuilder: panel.buildContent, + ), + ), + ); +} diff --git a/app/lib/reader/reader_panel_sheet.dart b/app/lib/reader/reader_panel_sheet.dart deleted file mode 100644 index 438a9df..0000000 --- a/app/lib/reader/reader_panel_sheet.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/themes/app_accessibility.dart'; -import 'package:papyrus/themes/app_motion.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; -import 'package:papyrus_reader/papyrus_reader.dart'; - -/// The client owns sheet presentation; the reader supplies live panel content. -Route buildReaderPanelSheet(BuildContext context, ReaderPanelRouteContext panel) { - final navigator = Navigator.of(context); - final theme = Theme.of(context); - final localizations = MaterialLocalizations.of(context); - - return ModalBottomSheetRoute( - capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), - barrierLabel: localizations.scrimLabel, - barrierOnTapHint: localizations.scrimOnTapHint(localizations.bottomSheetLabel), - modalBarrierColor: theme.bottomSheetTheme.modalBarrierColor, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: false, - sheetAnimationStyle: AppMotion.animationStyle(context), - shape: - theme.bottomSheetTheme.shape ?? - const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), - clipBehavior: Clip.antiAlias, - builder: (context) => ReaderSystemTextScope( - child: AppBottomSheet( - title: panel.title, - contentPadding: EdgeInsets.zero, - scrollBodyBuilder: panel.buildContent, - ), - ), - ); -} diff --git a/app/lib/widgets/book/book_bookmarks.dart b/app/lib/widgets/book/book_bookmarks.dart index cba2b6b..d96eb01 100644 --- a/app/lib/widgets/book/book_bookmarks.dart +++ b/app/lib/widgets/book/book_bookmarks.dart @@ -20,6 +20,7 @@ class BookBookmarks extends StatefulWidget { final String bookTitle; final bool isPhysical; final VoidCallback? onAddBookmark; + final ValueChanged? onBookmarkTap; /// Called when the user requests actions menu for a bookmark (long press). final Function(Bookmark)? onBookmarkActions; @@ -30,6 +31,7 @@ class BookBookmarks extends StatefulWidget { required this.bookTitle, this.isPhysical = false, this.onAddBookmark, + this.onBookmarkTap, this.onBookmarkActions, }); @@ -230,6 +232,7 @@ class _BookBookmarksState extends State { bookmark: bookmark, bookTitle: widget.bookTitle, showActionMenu: showActionMenu, + onTap: widget.onBookmarkTap == null ? null : () => widget.onBookmarkTap!(bookmark), onLongPress: () => widget.onBookmarkActions?.call(bookmark), ); } diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index 5562aeb..999c6e3 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -13,15 +13,26 @@ import 'package:papyrus/themes/app_motion.dart'; class AnnotationDialog extends StatefulWidget { final String bookId; final Annotation? existingAnnotation; + final BookLocation? initialLocation; + final String? initialText; final FutureOr Function(Annotation)? onSave; - const AnnotationDialog({super.key, required this.bookId, this.existingAnnotation, this.onSave}); + const AnnotationDialog({ + super.key, + required this.bookId, + this.existingAnnotation, + this.initialLocation, + this.initialText, + this.onSave, + }); /// Shows the dialog and returns the created/updated annotation, or null if cancelled. static Future show( BuildContext context, { required String bookId, Annotation? existingAnnotation, + BookLocation? initialLocation, + String? initialText, FutureOr Function(Annotation)? onSave, }) { return showModalBottomSheet( @@ -30,7 +41,13 @@ class AnnotationDialog extends StatefulWidget { isScrollControlled: true, useRootNavigator: true, useSafeArea: true, - builder: (context) => AnnotationDialog(bookId: bookId, existingAnnotation: existingAnnotation, onSave: onSave), + builder: (context) => AnnotationDialog( + bookId: bookId, + existingAnnotation: existingAnnotation, + initialLocation: initialLocation, + initialText: initialText, + onSave: onSave, + ), ); } @@ -46,14 +63,17 @@ class _AnnotationDialogState extends State with PersistentSave late final TextEditingController _noteController; late HighlightColor _selectedColor; + BookLocation? get _location => widget.existingAnnotation?.location ?? widget.initialLocation; + bool get _isAnchored => _location?.readerLocator != null; + bool get _isEditing => widget.existingAnnotation != null; @override void initState() { super.initState(); - _textController = TextEditingController(text: widget.existingAnnotation?.selectedText ?? ''); - _pageController = TextEditingController(text: widget.existingAnnotation?.location.pageNumber.toString() ?? ''); - _chapterController = TextEditingController(text: widget.existingAnnotation?.location.chapterTitle ?? ''); + _textController = TextEditingController(text: widget.existingAnnotation?.selectedText ?? widget.initialText ?? ''); + _pageController = TextEditingController(text: _location?.pageNumber.toString() ?? ''); + _chapterController = TextEditingController(text: _location?.chapterTitle ?? ''); _noteController = TextEditingController(text: widget.existingAnnotation?.note ?? ''); _selectedColor = widget.existingAnnotation?.color ?? HighlightColor.yellow; } @@ -76,7 +96,6 @@ class _AnnotationDialogState extends State with PersistentSave return; } - final page = int.parse(_pageController.text); final chapter = _chapterController.text.trim(); final note = _noteController.text.trim(); @@ -85,12 +104,14 @@ class _AnnotationDialogState extends State with PersistentSave bookId: widget.bookId, selectedText: _textController.text.trim(), color: _selectedColor, - location: BookLocation( - pageNumber: page, - chapterTitle: chapter.isNotEmpty ? chapter : null, - chapter: widget.existingAnnotation?.location.chapter, - percentage: widget.existingAnnotation?.location.percentage, - ), + location: _isEditing || _isAnchored + ? _location! + : BookLocation( + pageNumber: int.parse(_pageController.text), + chapterTitle: chapter.isNotEmpty ? chapter : null, + chapter: widget.existingAnnotation?.location.chapter, + percentage: widget.existingAnnotation?.location.percentage, + ), note: note.isNotEmpty ? note : null, createdAt: widget.existingAnnotation?.createdAt ?? DateTime.now(), updatedAt: _isEditing ? DateTime.now() : null, @@ -124,6 +145,7 @@ class _AnnotationDialogState extends State with PersistentSave // Highlighted text TextFormField( controller: _textController, + readOnly: _isAnchored, decoration: const InputDecoration( labelText: 'Highlighted text', hintText: 'Enter the passage you highlighted...', @@ -143,35 +165,35 @@ class _AnnotationDialogState extends State with PersistentSave ), const SizedBox(height: Spacing.md), - // Page number - TextFormField( - controller: _pageController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration(labelText: 'Page number', border: OutlineInputBorder()), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a page number'; - } - - final page = int.tryParse(value); - - if (page == null || page < 1) { - return 'Enter a valid page number'; - } - - return null; - }, - ), - const SizedBox(height: Spacing.md), + if (!_isEditing && !_isAnchored) ...[ + TextFormField( + controller: _pageController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Page number', border: OutlineInputBorder()), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a page number'; + } + + final page = int.tryParse(value); + + if (page == null || page < 1) { + return 'Enter a valid page number'; + } + + return null; + }, + ), + const SizedBox(height: Spacing.md), - // Chapter title (optional) - TextFormField( - controller: _chapterController, - decoration: const InputDecoration(labelText: 'Chapter title (optional)', border: OutlineInputBorder()), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: Spacing.md), + TextFormField( + controller: _chapterController, + decoration: const InputDecoration(labelText: 'Chapter title (optional)', border: OutlineInputBorder()), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: Spacing.md), + ], // Note (optional) TextFormField( diff --git a/app/lib/widgets/book_details/bookmark_dialog.dart b/app/lib/widgets/book_details/bookmark_dialog.dart index d9fc636..50acc5c 100644 --- a/app/lib/widgets/book_details/bookmark_dialog.dart +++ b/app/lib/widgets/book_details/bookmark_dialog.dart @@ -79,17 +79,19 @@ class _BookmarkDialogState extends State with PersistentSave with PersistentSave widget.pageCount!) { + return 'Page exceeds total pages (${widget.pageCount})'; + } + + return null; + }, ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a page number'; - } - - final page = int.tryParse(value); - - if (page == null || page < 1) { - return 'Enter a valid page number'; - } - - if (widget.pageCount != null && page > widget.pageCount!) { - return 'Page exceeds total pages (${widget.pageCount})'; - } - - return null; - }, - ), - const SizedBox(height: Spacing.md), + const SizedBox(height: Spacing.md), - // Chapter title (optional) - TextFormField( - controller: _chapterController, - decoration: const InputDecoration(labelText: 'Chapter title (optional)', border: OutlineInputBorder()), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: Spacing.md), + TextFormField( + controller: _chapterController, + decoration: const InputDecoration(labelText: 'Chapter title (optional)', border: OutlineInputBorder()), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: Spacing.md), + ], // Note (optional) TextFormField( diff --git a/app/lib/widgets/book_details/note_card.dart b/app/lib/widgets/book_details/note_card.dart index 7a559c5..d801bce 100644 --- a/app/lib/widgets/book_details/note_card.dart +++ b/app/lib/widgets/book_details/note_card.dart @@ -13,7 +13,7 @@ import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// - Title with truncation for long text /// - Content preview (first 3 lines) or full content /// - Tag chips for categorization -/// - Location and date metadata +/// - Date metadata /// - Action menu (via long press or icon button) /// /// ## Interactions @@ -174,18 +174,12 @@ class _NoteCardState extends State { ); } - /// Location and date metadata row. + /// Date metadata row. Widget _buildMetadata(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { final metaStyle = textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant); return Row( children: [ - if (widget.note.hasLocation) ...[ - Icon(Icons.location_on_outlined, size: IconSizes.small, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Flexible(child: Text(widget.note.location!.shortLocation, style: metaStyle)), - const SizedBox(width: Spacing.md), - ], Icon(Icons.access_time, size: IconSizes.small, color: colorScheme.onSurfaceVariant), const SizedBox(width: 4), Flexible(child: Text(widget.note.dateLabel, style: metaStyle)), diff --git a/app/lib/widgets/book_details/note_dialog.dart b/app/lib/widgets/book_details/note_dialog.dart index bbe0d5e..3067b4f 100644 --- a/app/lib/widgets/book_details/note_dialog.dart +++ b/app/lib/widgets/book_details/note_dialog.dart @@ -115,7 +115,6 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< bookId: widget.bookId, title: _titleController.text.trim(), content: _contentController.text.trim(), - location: widget.existingNote?.location, tags: _tags, isPinned: widget.existingNote?.isPinned ?? false, createdAt: widget.existingNote?.createdAt ?? DateTime.now(), diff --git a/app/pubspec.lock b/app/pubspec.lock index a3f3f90..aa5a85b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: 147291941203f0e444304f323a955412c3b31d0a - resolved-ref: 147291941203f0e444304f323a955412c3b31d0a + ref: "388a12a2ef26c933d59891416c17f4e69e4122e4" + resolved-ref: "388a12a2ef26c933d59891416c17f4e69e4122e4" url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 34a7fe0..e59bf32 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: 147291941203f0e444304f323a955412c3b31d0a + ref: 388a12a2ef26c933d59891416c17f4e69e4122e4 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 diff --git a/app/test/pages/annotation_edit_test.dart b/app/test/pages/annotation_edit_test.dart index ea77421..0dd04c0 100644 --- a/app/test/pages/annotation_edit_test.dart +++ b/app/test/pages/annotation_edit_test.dart @@ -11,7 +11,7 @@ import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; void main() { - testWidgets('annotation menu opens the complete prefilled editor and saves all fields', (tester) async { + testWidgets('annotation editor hides and preserves location while saving editable fields', (tester) async { await tester.binding.setSurfaceSize(const Size(1200, 1000)); addTearDown(() => tester.binding.setSurfaceSize(null)); final store = DataStore(); @@ -43,14 +43,13 @@ void main() { await tester.tap(find.text('Edit annotation')); await tester.pumpAndSettle(); expect(find.text('Original passage'), findsOneWidget); - expect(find.text('12'), findsOneWidget); - expect(find.text('Chapter'), findsOneWidget); + expect(find.widgetWithText(TextFormField, '12'), findsNothing); + expect(find.widgetWithText(TextFormField, 'Chapter'), findsNothing); expect(find.widgetWithText(TextFormField, 'Attached note'), findsOneWidget); final fields = find.byType(TextFormField); await tester.enterText(fields.at(0), 'Edited passage'); - await tester.enterText(fields.at(1), '24'); - await tester.enterText(fields.at(2), 'New chapter'); - await tester.enterText(fields.at(3), ''); + expect(fields, findsNWidgets(2)); + await tester.enterText(fields.at(1), ''); await tester.drag(find.byType(ListView).last, const Offset(0, -300)); await tester.pumpAndSettle(); expect(find.text('Highlight color'), findsOneWidget); @@ -59,8 +58,8 @@ void main() { await tester.pumpAndSettle(); final saved = store.getAnnotation('annotation')!; expect(saved.selectedText, 'Edited passage'); - expect(saved.location.pageNumber, 24); - expect(saved.location.chapterTitle, 'New chapter'); + expect(saved.location.pageNumber, 12); + expect(saved.location.chapterTitle, 'Chapter'); expect(saved.location.chapter, 2); expect(saved.location.percentage, 0.2); expect(saved.note, isNull); diff --git a/app/test/pages/book_details_reader_test.dart b/app/test/pages/book_details_reader_test.dart index d9858e1..deb740c 100644 --- a/app/test/pages/book_details_reader_test.dart +++ b/app/test/pages/book_details_reader_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; +import 'package:papyrus_reader/papyrus_reader.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -17,6 +19,90 @@ import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; void main() { + for (final width in [400.0, 1280.0]) { + for (final format in [BookFormat.epub, BookFormat.pdf]) { + testWidgets('saved items open their exact $format location at width $width', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final book = buildTestBook(id: 'saved-location-book', fileFormat: format); + final ReaderLocator bookmarkLocator = format == BookFormat.epub + ? EpubReaderLocator(spineIndex: 3, textOffset: 456, localProgression: .25, totalProgression: .4) + : PdfReaderLocator(pageIndex: 7, pageOffset: .35, totalProgression: .4); + final ReaderLocator annotationLocator = format == BookFormat.epub + ? EpubReaderLocator(spineIndex: 4, textOffset: 789, localProgression: .5, totalProgression: .6) + : PdfReaderLocator(pageIndex: 12, pageOffset: .65, totalProgression: .6); + final store = DataStore() + ..loadData( + books: [book], + bookmarks: [ + Bookmark( + id: 'bookmark', + bookId: book.id, + position: .4, + readerLocator: bookmarkLocator.toJson(), + note: 'Saved target', + createdAt: DateTime(2026), + ), + ], + annotations: [ + Annotation( + id: 'annotation', + bookId: book.id, + selectedText: 'Selected target', + location: BookLocation(pageNumber: 13, readerLocator: annotationLocator.toJson()), + createdAt: DateTime(2026), + ), + ], + ); + addTearDown(store.dispose); + String? routedLocation; + final router = GoRouter( + initialLocation: '/details/${book.id}', + routes: [ + GoRoute( + path: '/details/:bookId', + builder: (_, state) => Scaffold(body: BookDetailsPage(id: state.pathParameters['bookId'])), + ), + GoRoute( + name: 'BOOK_READER', + path: '/read/:bookId', + builder: (_, state) { + expect(state.pathParameters['bookId'], book.id); + routedLocation = state.uri.queryParameters['location']; + return const Scaffold(body: Text('Reader destination')); + }, + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + for (final (tab, label, locator) in [ + ('Bookmarks (1)', 'Saved target', bookmarkLocator), + ('Annotations (1)', '"Selected target"', annotationLocator), + ]) { + await tester.ensureVisible(find.text(tab)); + await tester.tap(find.text(tab)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(label)); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(find.text('Reader destination'), findsOneWidget); + expect(ReaderLocator.fromJson(Map.from(jsonDecode(routedLocation!) as Map)), locator); + router.pop(); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + } + } + for (final (size, textScale) in [ (const Size(400, 850), 1.0), (const Size(320, 568), 1.0), diff --git a/app/test/pages/reader_page_test.dart b/app/test/pages/reader_page_test.dart index 416ac79..a7e74aa 100644 --- a/app/test/pages/reader_page_test.dart +++ b/app/test/pages/reader_page_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -18,6 +19,112 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/test_helpers.dart'; void main() { + for (final format in [BookFormat.epub, BookFormat.pdf]) { + testWidgets('explicit $format location takes precedence over resume position', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + final ReaderLocator target = format == BookFormat.epub + ? EpubReaderLocator(spineIndex: 3, textOffset: 456, localProgression: .25, totalProgression: .4) + : PdfReaderLocator(pageIndex: 7, pageOffset: .35, totalProgression: .4); + final ReaderLocator resume = format == BookFormat.epub + ? EpubReaderLocator(spineIndex: 1, textOffset: 0, localProgression: 0, totalProgression: .1) + : PdfReaderLocator(pageIndex: 1, pageOffset: 0, totalProgression: .1); + final book = buildTestBook( + id: 'location-book', + fileFormat: format, + customMetadata: {'reader_locator': resume.toJson()}, + ); + final store = DataStore()..loadData(books: [book]); + addTearDown(store.dispose); + final cache = _ReaderMediaCache(); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: preferences), + Provider.value(value: cache), + Provider(create: (_) => BookImportService()), + ChangeNotifierProvider(create: (_) => _ReaderAuth()), + ], + child: MaterialApp( + home: ReaderPage(bookId: book.id, location: jsonEncode(target.toJson())), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final reader = tester.widget(find.byType(PapyrusReader)); + expect(reader.initialLocator, target); + expect(reader.onNote, isNull); + expect(cache.loads, 1); + expect(store.getBook(book.id)!.customMetadata!['reader_locator'], resume.toJson()); + final readerContext = tester.element(find.byType(PapyrusReader)); + + showDialog( + context: readerContext, + builder: (context) => Dialog( + child: Row( + mainAxisSize: MainAxisSize.min, + children: reader.builders.toolbarActions!( + context, + ReaderToolbarContext( + document: reader.document, + snapshot: ReaderReadySnapshot( + document: reader.document, + preferences: const ReaderPreferences(), + capabilities: const ReaderCapabilities(), + locator: target, + ), + openTableOfContents: () {}, + openSettings: () {}, + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect( + tester.getCenter(find.byTooltip('New note')).dx, + greaterThan(tester.getCenter(find.byTooltip('Add bookmark')).dx), + ); + await tester.tap(find.byTooltip('New note')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.enterText(find.widgetWithText(TextFormField, 'Title'), 'Freehand thought'); + await tester.enterText(find.byKey(const Key('note-content-field')), 'Independent of the reading position'); + await tester.tap(find.text('Save')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(store.notes.single.bookId, book.id); + expect(store.notes.single.content, 'Independent of the reading position'); + expect(store.notes.single.location, isNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets('invalid requested location does not fall back to the resume position', (tester) async { + final store = DataStore() + ..loadData( + books: [buildTestBook(id: 'epub-book', fileFormat: BookFormat.epub)], + ); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: const MaterialApp( + home: ReaderPage(bookId: 'epub-book', location: '[]'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('This saved location could not be opened.'), findsOneWidget); + expect(find.byType(PapyrusReader), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('host theme changes keep the same open document and avoid rereading media', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = PreferencesProvider(await SharedPreferences.getInstance()); diff --git a/app/test/pages/reader_theme_test.dart b/app/test/pages/reader_theme_test.dart index 2afa583..58fef1d 100644 --- a/app/test/pages/reader_theme_test.dart +++ b/app/test/pages/reader_theme_test.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/reader/reader_panel_route.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; @@ -29,26 +30,34 @@ void main() { addTearDown(controller.dispose); + final hostTheme = switch (host) { + 'dark' => AppTheme.dark, + 'eink' => AppTheme.eink, + _ => AppTheme.light, + }; + await tester.pumpWidget( MaterialApp( - theme: switch (host) { - 'dark' => AppTheme.dark, - 'eink' => AppTheme.eink, - _ => AppTheme.light, - }, + theme: hostTheme, home: PapyrusReader( document: ReaderDocument(id: 'theme', format: ReaderFormat.pdf, loadBytes: () async => Uint8List(0)), controller: controller, + builders: ReaderUiBuilders(compactPanelRoute: buildReaderPanelRoute), ), ), ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Reading settings'), findsNothing); + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('reader-content')))); + await tester.pump(const Duration(milliseconds: 350)); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Reading settings')); await tester.pumpAndSettle(); final label = find.text('Reading mode'); final readerTheme = Theme.of(tester.element(label)); final readerColors = readerTheme.colorScheme; + expect(readerColors, hostTheme.colorScheme); if (host == 'eink') { expect(readerTheme.extension()!.reduceAnimations, isTrue); @@ -58,12 +67,12 @@ void main() { } final labelText = tester.widget(find.descendant(of: label, matching: find.byType(RichText)).first); - expect(labelText.text.style!.color, readerColors.onSurfaceVariant); + expect(labelText.text.style!.color, hostTheme.inputDecorationTheme.labelStyle!.color); await tester.tap(find.text('Paginated').first); await tester.pumpAndSettle(); final option = find.text('Continuous scroll').last; final menuTheme = Theme.of(tester.element(option)); - expect(menuTheme.canvasColor, readerColors.surface); + expect(menuTheme.canvasColor, hostTheme.canvasColor); final optionText = tester.widget( find.descendant(of: option, matching: find.byType(RichText)).first, @@ -80,16 +89,16 @@ void main() { await tester.tap(option); await tester.pumpAndSettle(); expect(controller.preferences.layoutMode, ReaderLayoutMode.scroll); - // A mobile route captures the initial host theme. Reopen a popup - // after changing appearance while that settings route is still open. + // Page appearance changes must not recolor the app's controls. await tester.tap(find.text(reading == Brightness.light ? 'Night' : 'Light')); await tester.pumpAndSettle(); - await tester.tap(find.text('Automatic').first); + await tester.tap(find.text('Single').first); await tester.pumpAndSettle(); final columns = find.text('Double').last; final changedTheme = Theme.of(tester.element(columns)); - expect(changedTheme.brightness, reading == Brightness.light ? Brightness.dark : Brightness.light); - expect(changedTheme.canvasColor, changedTheme.colorScheme.surface); + expect(changedTheme.colorScheme, hostTheme.colorScheme); + expect(controller.preferences.brightness, reading == Brightness.light ? Brightness.dark : Brightness.light); + expect(changedTheme.canvasColor, hostTheme.canvasColor); final columnsText = tester.widget( find.descendant(of: columns, matching: find.byType(RichText)).first, diff --git a/app/test/pages/saved_items_navigation_test.dart b/app/test/pages/saved_items_navigation_test.dart new file mode 100644 index 0000000..0d20810 --- /dev/null +++ b/app/test/pages/saved_items_navigation_test.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/pages/annotations_page.dart'; +import 'package:papyrus/pages/bookmarks_page.dart'; +import 'package:papyrus/pages/notes_page.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + for (final width in [400.0, 1280.0]) { + for (final format in [BookFormat.epub, BookFormat.pdf]) { + testWidgets('dedicated saved items open exact $format locations at width $width', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final book = buildTestBook(id: 'saved-book', fileFormat: format); + final ReaderLocator bookmarkLocator = format == BookFormat.epub + ? EpubReaderLocator(spineIndex: 3, textOffset: 456, localProgression: .25, totalProgression: .4) + : PdfReaderLocator(pageIndex: 7, pageOffset: .35, totalProgression: .4); + final ReaderLocator annotationLocator = format == BookFormat.epub + ? EpubReaderLocator(spineIndex: 4, textOffset: 789, localProgression: .5, totalProgression: .6) + : PdfReaderLocator(pageIndex: 12, pageOffset: .65, totalProgression: .6); + final store = DataStore() + ..loadData( + books: [book], + bookmarks: [ + Bookmark( + id: 'bookmark', + bookId: book.id, + position: .4, + readerLocator: bookmarkLocator.toJson(), + note: 'Saved target', + createdAt: DateTime(2026), + ), + ], + annotations: [ + Annotation( + id: 'annotation', + bookId: book.id, + selectedText: 'Selected target', + location: BookLocation(pageNumber: 13, readerLocator: annotationLocator.toJson()), + createdAt: DateTime(2026), + ), + ], + ); + addTearDown(store.dispose); + String? routedLocation; + final router = GoRouter( + initialLocation: '/bookmarks', + routes: [ + GoRoute(path: '/bookmarks', builder: (_, _) => const BookmarksPage()), + GoRoute(path: '/annotations', builder: (_, _) => const AnnotationsPage()), + GoRoute( + name: 'BOOK_READER', + path: '/read/:bookId', + builder: (_, state) { + expect(state.pathParameters['bookId'], book.id); + routedLocation = state.uri.queryParameters['location']; + return const Scaffold(body: Text('Reader destination')); + }, + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + for (final (path, label, locator) in [ + ('/bookmarks', 'Saved target', bookmarkLocator), + ('/annotations', '"Selected target"', annotationLocator), + ]) { + router.go(path); + await tester.pumpAndSettle(); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(find.text('Reader destination'), findsOneWidget); + expect(ReaderLocator.fromJson(Map.from(jsonDecode(routedLocation!) as Map)), locator); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text(label), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); + } + + testWidgets('dedicated notes open an editor without reader navigation at width $width', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final book = buildTestBook(); + final store = DataStore() + ..loadData( + books: [book], + notes: [ + Note( + id: 'note', + bookId: book.id, + title: 'Freehand thoughts', + content: 'Remember this idea', + location: const BookLocation(pageNumber: 15), + createdAt: DateTime(2026), + ), + ], + ); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: const MaterialApp(home: NotesPage()), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Freehand thoughts')); + await tester.pumpAndSettle(); + final contentField = find.byKey(const Key('note-content-field')); + expect(contentField, findsOneWidget); + await tester.enterText(contentField, 'Updated independent thought'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(store.notes.single.content, 'Updated independent thought'); + expect(store.notes.single.location, isNull); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/app/test/powersync/reader_location_persistence_test.dart b/app/test/powersync/reader_location_persistence_test.dart new file mode 100644 index 0000000..ce44131 --- /dev/null +++ b/app/test/powersync/reader_location_persistence_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/reader/reader_library.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +import '../helpers/test_helpers.dart'; +import 'powersync_service_test.dart' show OfflineConnector; + +void main() { + for (final guest in [true, false]) { + test('reader anchors and highlights survive reopening ${guest ? 'guest' : 'account'} storage', () async { + final directory = await Directory.systemTemp.createTemp('papyrus-reader-locations-'); + PapyrusPowerSyncService open() => PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + Future activate(PapyrusPowerSyncService db) => guest ? db.activateGuest() : db.activateAuthenticated('one'); + var db = open(); + await activate(db); + await db.upsert(buildTestBook(id: 'book')); + final start = EpubReaderLocator(spineIndex: 2, textOffset: 42, localProgression: .1, totalProgression: .25); + final end = EpubReaderLocator(spineIndex: 2, textOffset: 47, localProgression: .2, totalProgression: .25); + final selection = ReaderSelection(text: 'Quote', start: start, end: end); + final location = BookLocation(pageNumber: 1, readerLocator: start.toJson(), selection: selection.toJson()); + final bookmark = Bookmark( + id: 'bookmark', + bookId: 'book', + position: .25, + readerLocator: start.toJson(), + createdAt: DateTime.utc(2026), + ); + final annotation = Annotation( + id: 'annotation', + bookId: 'book', + selectedText: 'Quote', + location: location, + createdAt: DateTime.utc(2026), + ); + final note = Note( + id: 'note', + bookId: 'book', + title: 'Thought', + content: 'Remember', + createdAt: DateTime.utc(2026), + ); + await db.bookmarks.upsert(bookmark); + await db.annotations.upsert(annotation); + await db.notes.upsert(note); + expect(decodeLibraryRow(bookmarkRowMapper.toRow(bookmark))['reader_locator'], start.toJson()); + expect( + (decodeLibraryRow(annotationRowMapper.toRow(annotation))['location'] as Map)['selection'], + selection.toJson(), + ); + await db.close(); + db = open(); + await activate(db); + expect((await db.bookmarks.getById('bookmark'))!.readerLocator, start.toJson()); + expect((await db.annotations.getById('annotation'))!.location.selection, selection.toJson()); + expect((await db.notes.getById('note'))!.location, isNull); + expect((await db.notes.getById('note'))!.content, 'Remember'); + final store = DataStore(bookRepository: db); + await store.waitUntilLoaded(); + final library = ReaderLibrary(store: store, bookId: 'book'); + expect(library.highlights.single.selection.start, start); + await db.activateAuthenticated('other'); + expect(library.isCurrent, isFalse); + expect(library.highlights, isEmpty); + expect(() => library.delete(annotation), throwsStateError); + expect(await db.notes.getById('note'), isNull); + await store.disposeBookRepository(); + await db.close(); + await directory.delete(recursive: true); + }); + } +} diff --git a/app/test/reader/reader_book_adapter_test.dart b/app/test/reader/reader_book_adapter_test.dart index d766dd8..3c52c74 100644 --- a/app/test/reader/reader_book_adapter_test.dart +++ b/app/test/reader/reader_book_adapter_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; @@ -11,6 +12,20 @@ import '../helpers/test_helpers.dart'; void main() { group('ReaderBookAdapter', () { + test('saved locations preserve anchors and reject missing or mismatched EPUB positions', () { + final epub = buildTestBook(fileFormat: BookFormat.epub); + final pdf = buildTestBook(fileFormat: BookFormat.pdf, pageCount: 100); + final locator = EpubReaderLocator(spineIndex: 3, textOffset: 456, localProgression: .25, totalProgression: .4); + expect(ReaderBookAdapter.decodeLocation(epub, jsonEncode(locator.toJson())), locator); + expect(() => ReaderBookAdapter.decodeLocation(pdf, jsonEncode(locator.toJson())), throwsFormatException); + expect(() => ReaderBookAdapter.decodeLocation(epub, '[]'), throwsFormatException); + expect(() => ReaderBookAdapter.decodeLocation(epub, '{broken'), throwsFormatException); + expect(() => ReaderBookAdapter.savedLocator(epub, pageNumber: 20), throwsFormatException); + final page = ReaderBookAdapter.savedLocator(pdf, pageNumber: 20) as PdfReaderLocator; + expect(page.pageIndex, 19); + expect(page.pageOffset, 0); + }); + test('maps only EPUB and PDF formats', () { expect(ReaderBookAdapter.formatFor(BookFormat.epub), ReaderFormat.epub); expect(ReaderBookAdapter.formatFor(BookFormat.pdf), ReaderFormat.pdf); diff --git a/app/test/reader/reader_library_panel_test.dart b/app/test/reader/reader_library_panel_test.dart new file mode 100644 index 0000000..2592d5d --- /dev/null +++ b/app/test/reader/reader_library_panel_test.dart @@ -0,0 +1,394 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/reader/reader_library.dart'; +import 'package:papyrus/reader/reader_bookmark_button.dart'; +import 'package:papyrus/reader/reader_library_panel.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +class _Entities implements EntityRepository { + _Entities(this.id); + final String Function(T) id; + final values = {}; + bool fail = false; + Completer? pending; + + @override + Future getById(String id) async => values[id]; + @override + Future delete(String id) async { + await pending?.future; + if (fail) throw StateError('Storage unavailable'); + values.remove(id); + } + + @override + Future upsert(T value, {T? previous}) async { + await pending?.future; + if (fail) throw StateError('Storage unavailable'); + values[id(value)] = value; + } +} + +class _Repository extends Fake implements EditableBookRepository, LibraryRepository { + @override + bool isCurrent = true; + @override + EditableBookRepository get scopedBooks => this; + @override + final _Entities bookmarks = _Entities((value) => value.id); + @override + final _Entities annotations = _Entities((value) => value.id); + @override + final _Entities notes = _Entities((value) => value.id); + @override + Stream watchLibrary() => Stream.value(const LibrarySnapshot()); +} + +void main() { + final locator = EpubReaderLocator(spineIndex: 2, textOffset: 120, localProgression: .2, totalProgression: .4); + final selection = ReaderSelection( + text: 'A remembered passage', + start: locator, + end: EpubReaderLocator(spineIndex: 2, textOffset: 140, localProgression: .3, totalProgression: .4), + ); + + final snapshot = ReaderReadySnapshot( + document: ReaderDocument(id: 'book', format: ReaderFormat.epub, loadBytes: () async => Uint8List(0)), + preferences: const ReaderPreferences(), + capabilities: const ReaderCapabilities(), + locator: locator, + toc: [ReaderTocEntry(title: 'Third chapter', locator: locator)], + ); + + test('saved EPUB titles follow the nearest preceding section, including selection starts', () { + final store = DataStore(bookRepository: _Repository()); + addTearDown(store.dispose); + final library = ReaderLibrary(store: store, bookId: 'book'); + + EpubReaderLocator at(int offset) => EpubReaderLocator( + spineIndex: 2, + textOffset: offset, + localProgression: offset / 1000, + totalProgression: .4, + ); + + final toc = [ + ReaderTocEntry(title: 'Later section', locator: at(500)), + ReaderTocEntry( + title: 'Chapter start', + locator: at(0), + children: [ReaderTocEntry(title: 'Early section', locator: at(100))], + ), + ReaderTocEntry( + title: 'Unresolved section', + locator: EpubReaderLocator(spineIndex: 2, anchor: 'missing', localProgression: 0, totalProgression: .4), + ), + ]; + + final current = ReaderReadySnapshot( + document: snapshot.document, + preferences: snapshot.preferences, + capabilities: snapshot.capabilities, + locator: at(600), + toc: toc, + ); + + expect(library.location(current).chapterTitle, 'Later section'); + + for (final (offset, title) in [(50, 'Chapter start'), (100, 'Early section'), (120, 'Early section')]) { + final selected = ReaderSelection(text: 'Text', start: at(offset), end: at(offset + 10)); + final saved = library.location(current, selection: selected); + expect(saved.chapterTitle, title); + expect(saved.readerLocator, selected.start.toJson()); + } + }); + + test('saved EPUB titles compare progression when offsets are unavailable', () { + final store = DataStore(bookRepository: _Repository()); + addTearDown(store.dispose); + final library = ReaderLibrary(store: store, bookId: 'book'); + + EpubReaderLocator at(double progress) => EpubReaderLocator( + spineIndex: 2, + localProgression: progress, + totalProgression: .4, + ); + + final current = ReaderReadySnapshot( + document: snapshot.document, + preferences: snapshot.preferences, + capabilities: snapshot.capabilities, + locator: at(.3), + toc: [ + ReaderTocEntry(title: 'Start', locator: at(0)), + ReaderTocEntry(title: 'Early section', locator: at(.2)), + ReaderTocEntry(title: 'Later section', locator: at(.5)), + ], + ); + + expect(library.location(current).chapterTitle, 'Early section'); + }); + + for (final (width, eink) in [(390.0, false), (1000.0, false), (390.0, true)]) { + testWidgets('create, view and navigate saved reader items at width $width, eink $eink', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 844); + addTearDown(tester.view.reset); + final repository = _Repository(); + final store = DataStore(bookRepository: repository); + final library = ReaderLibrary(store: store, bookId: 'book'); + final controller = ReaderController(registry: ReaderEngineRegistry.defaults()); + addTearDown(controller.dispose); + addTearDown(store.dispose); + ReaderLocator? navigated; + late BuildContext hostContext; + + await tester.pumpWidget( + MaterialApp( + theme: eink ? AppTheme.eink : AppTheme.light, + home: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: width < 720 ? width : 420, + child: ReaderLibraryPanel( + library: library, + panel: ReaderPanelContext( + snapshot: snapshot, + controller: controller, + close: () {}, + showHeader: width >= 720, + navigate: (locator) => navigated = locator, + buildContents: (_) => const Center(child: Text('Third chapter')), + ), + ), + ), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bookmarks')); + await tester.pumpAndSettle(); + final bookmarkFab = find.byTooltip('Bookmark this location'); + expect(find.byType(FloatingActionButton), findsOneWidget); + final panelBounds = tester.getRect(find.byType(ReaderLibraryPanel)); + final bookmarkBounds = tester.getRect(bookmarkFab); + expect(bookmarkBounds.right, panelBounds.right - 16); + expect(bookmarkBounds.bottom, panelBounds.bottom - 16); + await tester.tap(bookmarkFab); + await tester.pumpAndSettle(); + expect(repository.bookmarks.values.values.single.readerLocator, locator.toJson()); + expect(find.byTooltip('Remove bookmark'), findsOneWidget); + expect(find.byIcon(Icons.bookmark), findsWidgets); + expect(tester.widget(find.byType(FloatingActionButton)).onPressed, isNotNull); + await library.addBookmark(hostContext, snapshot); + expect(repository.bookmarks.values, hasLength(1)); + await tester.tap(find.text('Third chapter')); + expect(navigated, locator); + final bookmark = repository.bookmarks.values.values.single; + library.editBookmark(hostContext, bookmark); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextFormField, 'Note (optional)'), 'Return here'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(repository.bookmarks.values.values.single.readerLocator, locator.toJson()); + expect(repository.bookmarks.values.values.single.position, bookmark.position); + expect(repository.bookmarks.values.values.single.note, 'Return here'); + + library.annotate(hostContext, snapshot, selection); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(repository.annotations.values.values.single.selectedText, selection.text); + expect(library.highlights.single.selection.start, locator); + await tester.ensureVisible(find.text('Annotations')); + await tester.tap(find.text('Annotations')); + await tester.pumpAndSettle(); + await tester.tap(find.text(selection.text)); + await tester.pumpAndSettle(); + expect(find.text('Go to passage'), findsOneWidget); + await tester.tap(find.text('Go to passage')); + await tester.pumpAndSettle(); + expect(navigated, locator); + + final highlight = library.highlights.single; + library.editHighlight(hostContext, highlight); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextFormField, 'Note (optional)'), 'Edited from the passage'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(repository.annotations.values.values.single.note, 'Edited from the passage'); + expect(repository.annotations.values.values.single.location.selection, selection.toJson()); + library.removeHighlight(hostContext, highlight); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(library.highlights, hasLength(1)); + repository.annotations.fail = true; + library.removeHighlight(hostContext, highlight); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(repository.annotations.values, hasLength(1)); + expect(library.highlights, hasLength(1)); + expect(find.text('Could not delete item. Try again.'), findsOneWidget); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + repository.annotations.fail = false; + library.removeHighlight(hostContext, highlight); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(repository.annotations.values, isEmpty); + expect(library.highlights, isEmpty); + + await tester.ensureVisible(find.text('Notes')); + await tester.tap(find.text('Notes')); + await tester.pumpAndSettle(); + final noteFab = find.byTooltip('Add note'); + expect(find.byType(FloatingActionButton), findsOneWidget); + final noteBounds = tester.getRect(noteFab); + expect(noteBounds.right, panelBounds.right - 16); + expect(noteBounds.bottom, panelBounds.bottom - 16); + await tester.tap(noteFab); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextFormField, 'Title'), 'My thought'); + await tester.enterText(find.byKey(const Key('note-content-field')), 'The full note content'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(repository.notes.values.values.single.location, isNull); + await tester.tap(find.text('My thought')); + await tester.pumpAndSettle(); + expect(find.text('The full note content'), findsWidgets); + expect(find.text('Go to passage'), findsNothing); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets('bookmark feedback tracks pending, failed, saved and deleted states', (tester) async { + final repository = _Repository(); + final store = DataStore(bookRepository: repository); + final library = ReaderLibrary(store: store, bookId: 'book'); + addTearDown(store.dispose); + late BuildContext hostContext; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return ReaderBookmarkButton(library: library, snapshot: snapshot); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + repository.bookmarks.fail = true; + await tester.tap(find.byTooltip('Add bookmark')); + await tester.pumpAndSettle(); + expect(repository.bookmarks.values, isEmpty); + expect(find.byTooltip('Add bookmark'), findsOneWidget); + expect(find.text('Could not save bookmark. Try again.'), findsOneWidget); + repository.bookmarks.fail = false; + repository.bookmarks.pending = Completer(); + await tester.tap(find.byTooltip('Add bookmark')); + await tester.pump(); + expect(find.byTooltip('Saving bookmark…'), findsOneWidget); + await library.addBookmark(hostContext, snapshot); + repository.bookmarks.pending!.complete(); + await tester.pumpAndSettle(); + expect(find.byTooltip('Remove bookmark'), findsOneWidget); + expect(repository.bookmarks.values, hasLength(1)); + expect(find.byIcon(Icons.bookmark), findsOneWidget); + final bookmark = repository.bookmarks.values.values.single; + final reflowed = ReaderReadySnapshot( + document: snapshot.document, + preferences: snapshot.preferences, + capabilities: snapshot.capabilities, + locator: EpubReaderLocator(spineIndex: 2, textOffset: 100, localProgression: .1, totalProgression: .3), + coverage: const [ReaderContentCoverage(key: 'epub:2:1000', start: .1, end: .2)], + ); + expect(library.bookmarkAt(reflowed)?.id, bookmark.id); + await library.addBookmark(hostContext, reflowed); + expect(repository.bookmarks.values, hasLength(1)); + repository.bookmarks.fail = true; + await tester.tap(find.byTooltip('Remove bookmark')); + await tester.pumpAndSettle(); + expect(repository.bookmarks.values, hasLength(1)); + expect(find.byIcon(Icons.bookmark), findsOneWidget); + expect(find.text('Could not remove bookmark. Try again.'), findsOneWidget); + repository.bookmarks.fail = false; + repository.bookmarks.pending = Completer(); + await tester.tap(find.byTooltip('Remove bookmark')); + await tester.pump(); + expect(find.byTooltip('Removing bookmark…'), findsOneWidget); + expect(tester.widget(find.byType(IconButton)).onPressed, isNull); + await library.toggleBookmark(hostContext, snapshot); + repository.bookmarks.pending!.complete(); + await tester.pumpAndSettle(); + expect(repository.bookmarks.values, isEmpty); + expect(find.byTooltip('Add bookmark'), findsOneWidget); + expect(find.byIcon(Icons.bookmark), findsNothing); + await tester.tap(find.byTooltip('Add bookmark')); + await tester.pumpAndSettle(); + expect(repository.bookmarks.values, hasLength(1)); + expect(find.byTooltip('Remove bookmark'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed and stale annotation editors retain their draft', (tester) async { + final repository = _Repository(); + final store = DataStore(bookRepository: repository); + final library = ReaderLibrary(store: store, bookId: 'book'); + addTearDown(store.dispose); + late BuildContext hostContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + library.annotate(hostContext, snapshot, selection); + await tester.pumpAndSettle(); + repository.annotations.fail = true; + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('New annotation'), findsOneWidget); + expect(repository.annotations.values, isEmpty); + repository.annotations.fail = false; + repository.isCurrent = false; + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('New annotation'), findsOneWidget); + expect(repository.annotations.values, isEmpty); + expect(library.highlights, isEmpty); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/app/test/reader/reader_panel_navigation_test.dart b/app/test/reader/reader_panel_navigation_test.dart new file mode 100644 index 0000000..cfd0d17 --- /dev/null +++ b/app/test/reader/reader_panel_navigation_test.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/reader/reader_panel_route.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +void main() { + testWidgets('full-screen contents returns to the same reader and navigates to the selected chapter', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + final facade = _PanelPdfFacade(); + var loads = 0; + + final controller = ReaderController( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.pdf}, + factory: () => PdfReaderEngine(facadeFactory: (_) async => facade), + ), + ]), + ); + + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: PapyrusReader( + document: ReaderDocument( + id: 'contents', + title: 'A book', + format: ReaderFormat.pdf, + loadBytes: () async { + loads++; + return Uint8List(0); + }, + ), + controller: controller, + builders: ReaderUiBuilders( + compactPanelRoute: (context, panel) => buildReaderPanelRoute(context, panel, bookTitle: 'A book'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final locator = controller.snapshot.locator; + final viewport = find.byKey(const ValueKey('book-viewport')); + final bounds = tester.getRect(viewport); + expect(find.byTooltip('Table of contents'), findsNothing); + await tester.tapAt(bounds.center); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Table of contents')); + await tester.pumpAndSettle(); + expect(find.text('A book'), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(controller.snapshot.locator, locator); + expect(tester.getRect(viewport), bounds); + await tester.tap(find.byTooltip('Table of contents')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Second chapter')); + await tester.pumpAndSettle(); + expect((controller.snapshot.locator as PdfReaderLocator).pageIndex, 12); + expect(find.text('Contents'), findsNothing); + expect(tester.getRect(viewport), bounds); + expect(loads, 1); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} + +class _PanelPdfFacade implements PdfFacade { + @override + int get pageCount => 80; + + @override + List get outline => const [ + PdfFacadeOutlineEntry(title: 'First chapter', pageIndex: 0), + PdfFacadeOutlineEntry(title: 'Second chapter', pageIndex: 12), + ]; + + @override + Future showPage(int pageIndex, double pageOffset) async {} + + @override + Widget buildViewport(PdfViewportConfiguration configuration) => const SizedBox.expand(key: ValueKey('book-viewport')); +} diff --git a/app/test/reader/reader_panel_sheet_test.dart b/app/test/reader/reader_panel_route_test.dart similarity index 59% rename from app/test/reader/reader_panel_sheet_test.dart rename to app/test/reader/reader_panel_route_test.dart index 6ea79a9..3e3d1d2 100644 --- a/app/test/reader/reader_panel_sheet_test.dart +++ b/app/test/reader/reader_panel_route_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:papyrus/reader/reader_panel_sheet.dart'; +import 'package:papyrus/reader/reader_panel_route.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; @@ -27,10 +27,10 @@ void main() { builder: (context) => TextButton( onPressed: () { route = - buildReaderPanelSheet( + buildReaderPanelRoute( context, ReaderPanelRouteContext( - kind: ReaderPanelKind.tableOfContents, + kind: ReaderPanelKind.settings, title: 'Contents', buildContent: (context, controller) { contentController = controller; @@ -67,7 +67,7 @@ void main() { expect(Theme.of(tester.element(find.byType(AppBottomSheet))).brightness, theme.brightness); if (eink) { - expect(route.transitionDuration, Duration.zero); + expect((route as TransitionRoute).transitionDuration, Duration.zero); expect(route.shape, theme.bottomSheetTheme.shape); } @@ -93,6 +93,83 @@ void main() { }); } + for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { + testWidgets( + 'contents opens a full page with scrolling and back navigation (${theme.brightness}, ${theme.splashFactory})', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = const FakeViewPadding(top: 24, bottom: 34); + addTearDown(tester.view.reset); + late Route route; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () { + route = buildReaderPanelRoute( + context, + ReaderPanelRouteContext( + kind: ReaderPanelKind.tableOfContents, + title: 'Contents', + buildContent: (context, controller) => ListView.builder( + controller: controller, + itemCount: 80, + itemBuilder: (context, index) => ListTile( + title: Text('Chapter $index'), + onTap: () => Navigator.of(context).pop(), + ), + ), + ), + bookTitle: 'A book with a long title that should fit on a mobile screen', + ); + Navigator.of(context).push(route); + }, + child: const Text('Open contents'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open contents')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + expect(find.byType(BackButton), findsOneWidget); + expect(tester.getRect(find.byType(Scaffold)), const Rect.fromLTWH(0, 0, 390, 844)); + expect(Theme.of(tester.element(find.text('Chapter 0'))).colorScheme, theme.colorScheme); + expect(tester.getBottomRight(find.byType(ListView)).dy, 810); + + if (theme == AppTheme.eink) { + expect((route as TransitionRoute).transitionDuration, Duration.zero); + } + + final headerPosition = tester.getTopLeft(find.text('Contents')); + await tester.drag(find.byType(ListView), const Offset(0, -600)); + await tester.pumpAndSettle(); + expect(find.text('Chapter 0').hitTestable(), findsNothing); + expect(tester.getTopLeft(find.text('Contents')), headerPosition); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Open contents'), findsOneWidget); + await tester.tap(find.text('Open contents')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Chapter 0')); + await tester.pumpAndSettle(); + expect(find.text('Open contents'), findsOneWidget); + await tester.tap(find.text('Open contents')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Open contents'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('short PDF-style panels fit their content at large text scale', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 844); @@ -109,7 +186,7 @@ void main() { builder: (context) => Scaffold( body: TextButton( onPressed: () => Navigator.of(context).push( - buildReaderPanelSheet( + buildReaderPanelRoute( context, ReaderPanelRouteContext( kind: ReaderPanelKind.settings, diff --git a/app/test/widgets/book_details/annotation_dialog_test.dart b/app/test/widgets/book_details/annotation_dialog_test.dart index e075d7a..0e3f53b 100644 --- a/app/test/widgets/book_details/annotation_dialog_test.dart +++ b/app/test/widgets/book_details/annotation_dialog_test.dart @@ -1,8 +1,99 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; void main() { + testWidgets('reader annotation hides location while preserving its saved anchor', (tester) async { + const location = BookLocation( + pageNumber: 1, + chapterTitle: 'CHAPTER VI. Pig and Pepper', + percentage: .4, + readerLocator: {'type': 'epub', 'spineIndex': 6, 'textOffset': 123}, + ); + Annotation? saved; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => AnnotationDialog.show( + context, + bookId: 'book', + initialText: 'Selected passage', + initialLocation: location, + onSave: (annotation) => saved = annotation, + ), + child: const Text('Annotate'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Annotate')); + await tester.pumpAndSettle(); + expect(find.text(location.displayLocation), findsNothing); + expect(find.text('Page number'), findsNothing); + expect(find.text('Chapter title (optional)'), findsNothing); + await tester.enterText(find.widgetWithText(TextFormField, 'Note (optional)'), 'My annotation'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(saved?.location, location); + expect(saved?.selectedText, 'Selected passage'); + expect(saved?.note, 'My annotation'); + expect(tester.takeException(), isNull); + }); + + for (final anchored in [false, true]) { + testWidgets('annotation editing hides and preserves location, anchored $anchored', (tester) async { + final existing = Annotation( + id: 'annotation', + bookId: 'book', + selectedText: 'Selected passage', + location: BookLocation( + pageNumber: 30, + chapterTitle: 'CHAPTER VI. Pig and Pepper', + percentage: .4, + readerLocator: anchored ? {'type': 'epub', 'spineIndex': 6, 'textOffset': 123} : null, + ), + createdAt: DateTime.utc(2026), + ); + Annotation? saved; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => AnnotationDialog.show( + context, + bookId: existing.bookId, + existingAnnotation: existing, + onSave: (annotation) => saved = annotation, + ), + child: const Text('Edit'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(find.text(existing.location.displayLocation), findsNothing); + expect(find.text('Page number'), findsNothing); + expect(find.text('Chapter title (optional)'), findsNothing); + await tester.enterText(find.widgetWithText(TextFormField, 'Note (optional)'), 'Updated annotation'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(saved!.location, existing.location); + expect(saved!.note, 'Updated annotation'); + expect(tester.takeException(), isNull); + }); + } + testWidgets('annotation sheet sizes to its form content', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/app/test/widgets/book_details/bookmark_dialog_test.dart b/app/test/widgets/book_details/bookmark_dialog_test.dart index dd85369..dcab18f 100644 --- a/app/test/widgets/book_details/bookmark_dialog_test.dart +++ b/app/test/widgets/book_details/bookmark_dialog_test.dart @@ -38,6 +38,44 @@ void main() { await tester.pumpAndSettle(); } + for (final anchored in [false, true]) { + testWidgets('bookmark editing hides and preserves location, anchored $anchored', (tester) async { + final existing = Bookmark( + id: 'bookmark', + bookId: 'book', + position: .4, + pageNumber: anchored ? null : 30, + chapterTitle: 'CHAPTER VI. Pig and Pepper', + readerLocator: anchored ? {'type': 'epub', 'spineIndex': 6, 'textOffset': 123} : null, + createdAt: DateTime.utc(2026), + ); + Bookmark? saved; + + await open( + tester, + (context) => BookmarkDialog.show( + context, + bookId: existing.bookId, + existingBookmark: existing, + onSave: (bookmark) => saved = bookmark, + ), + ); + + expect(find.text(existing.displayLocation), findsNothing); + expect(find.text('Page number'), findsNothing); + expect(find.text('Chapter title (optional)'), findsNothing); + await tester.enterText(find.widgetWithText(TextFormField, 'Note (optional)'), 'Updated note'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(saved!.readerLocator, existing.readerLocator); + expect(saved!.pageNumber, existing.pageNumber); + expect(saved!.chapterTitle, existing.chapterTitle); + expect(saved!.position, existing.position); + expect(saved!.note, 'Updated note'); + expect(tester.takeException(), isNull); + }); + } + for (final kind in ['create', 'note', 'color', 'delete']) { for (final fails in [false, true]) { testWidgets('bookmark $kind waits for write and ${fails ? 'keeps failures open' : 'closes after success'}', ( diff --git a/app/test/widgets/book_details/note_card_test.dart b/app/test/widgets/book_details/note_card_test.dart index 7da9dc1..309221c 100644 --- a/app/test/widgets/book_details/note_card_test.dart +++ b/app/test/widgets/book_details/note_card_test.dart @@ -63,10 +63,10 @@ void main() { expect(find.widgetWithText(Chip, 'important'), findsOneWidget); }); - testWidgets('displays location with icon when present', (tester) async { + testWidgets('does not display stored reading location metadata', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.byIcon(Icons.location_on_outlined), findsOneWidget); - expect(find.text('Ch. 5, p. 120'), findsOneWidget); + expect(find.byIcon(Icons.location_on_outlined), findsNothing); + expect(find.text('Ch. 5, p. 120'), findsNothing); }); testWidgets('hides location when not set', (tester) async {