From 381c6b31ffd788275ff27088037f5d4c43c7cd8c Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 5 Sep 2026 23:15:37 +0300 Subject: [PATCH 1/3] Persist and synchronize library entities across account and guest profiles --- app/lib/data/data_store.dart | 369 +++++++++++++----- .../data/repositories/library_repository.dart | 62 +++ app/lib/models/annotation.dart | 3 +- app/lib/models/book.dart | 36 +- app/lib/models/note.dart | 3 +- app/lib/models/shelf.dart | 80 +++- app/lib/models/tag.dart | 11 +- app/lib/pages/annotations_page.dart | 28 +- app/lib/pages/book_details_page.dart | 66 +++- app/lib/pages/notes_page.dart | 22 +- app/lib/pages/shelf_contents_page.dart | 7 +- app/lib/pages/shelves_page.dart | 33 +- app/lib/powersync/library_database.dart | 267 +++++++++++++ app/lib/powersync/library_row_mapper.dart | 90 +++++ .../papyrus_powersync_connector.dart | 3 + app/lib/powersync/papyrus_schema.dart | 99 ++++- app/lib/powersync/powersync_book_mapper.dart | 86 ++-- app/lib/powersync/powersync_service.dart | 94 ++++- app/lib/providers/annotations_provider.dart | 20 +- app/lib/providers/book_details_provider.dart | 60 +-- app/lib/providers/book_edit_provider.dart | 5 +- app/lib/providers/notes_provider.dart | 13 +- app/lib/providers/shelves_provider.dart | 36 +- app/lib/utils/book_actions.dart | 50 +-- app/lib/utils/bulk_book_actions.dart | 37 +- .../annotations/annotation_action_sheet.dart | 23 +- app/lib/widgets/book/book_details.dart | 46 +-- .../book_details/annotation_dialog.dart | 28 +- app/lib/widgets/book_details/note_dialog.dart | 32 +- app/lib/widgets/shared/persistent_save.dart | 26 ++ app/lib/widgets/shelves/add_shelf_sheet.dart | 27 +- .../widgets/shelves/move_to_shelf_sheet.dart | 35 +- app/lib/widgets/topics/add_topic_sheet.dart | 25 +- .../widgets/topics/manage_topics_sheet.dart | 35 +- .../widgets/topics/topic_detail_sheet.dart | 24 +- app/pubspec.lock | 2 +- app/pubspec.yaml | 1 + .../models/library_nullable_fields_test.dart | 116 ++++++ app/test/models/shelf_icon_test.dart | 81 ++++ .../powersync/library_live_sync_test.dart | 234 +++++++++++ .../powersync/library_persistence_test.dart | 208 ++++++++++ .../powersync/papyrus_schema_mode_test.dart | 14 +- .../powersync/powersync_book_mapper_test.dart | 85 +++- .../providers/book_details_provider_test.dart | 41 +- .../providers/book_edit_persistence_test.dart | 62 +++ app/test/widgets/persistent_editor_test.dart | 104 +++++ 46 files changed, 2378 insertions(+), 451 deletions(-) create mode 100644 app/lib/data/repositories/library_repository.dart create mode 100644 app/lib/powersync/library_database.dart create mode 100644 app/lib/powersync/library_row_mapper.dart create mode 100644 app/lib/widgets/shared/persistent_save.dart create mode 100644 app/test/models/library_nullable_fields_test.dart create mode 100644 app/test/models/shelf_icon_test.dart create mode 100644 app/test/powersync/library_live_sync_test.dart create mode 100644 app/test/powersync/library_persistence_test.dart create mode 100644 app/test/providers/book_edit_persistence_test.dart create mode 100644 app/test/widgets/persistent_editor_test.dart diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index fe864cd..d1d0b1b 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.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/book.dart'; import 'package:papyrus/models/book_shelf_relation.dart'; @@ -20,7 +21,7 @@ class DataStore extends ChangeNotifier { DataStore({BookRepository? bookRepository}) { final repository = bookRepository ?? InMemoryBookRepository(); _bookRepository = repository; - _bookSubscription = repository.watchAll().listen(replaceBooksFromSync); + _listenRepository(repository); } // Primary data collections (keyed by ID) @@ -41,6 +42,50 @@ class DataStore extends ChangeNotifier { bool _isLoaded = false; BookRepository? _bookRepository; StreamSubscription>? _bookSubscription; + StreamSubscription? _librarySubscription; + + LibraryRepository? get libraryRepository { + final repository = _bookRepository; + return repository is LibraryRepository ? repository as LibraryRepository : null; + } + + void _listenRepository(BookRepository repository) { + if (repository is LibraryRepository) { + _librarySubscription = (repository as LibraryRepository).watchLibrary().listen( + _replaceLibrary, + onError: (Object error, StackTrace stack) => + FlutterError.reportError(FlutterErrorDetails(exception: error, stack: stack, library: 'papyrus library')), + ); + } else { + _bookSubscription = repository.watchAll().listen(replaceBooksFromSync); + } + } + + void _replaceLibrary(LibrarySnapshot snapshot) { + _shelves + ..clear() + ..addEntries(snapshot.shelves.map((value) => MapEntry(value.id, value))); + _tags + ..clear() + ..addEntries(snapshot.tags.map((value) => MapEntry(value.id, value))); + _notes + ..clear() + ..addEntries(snapshot.notes.map((value) => MapEntry(value.id, value))); + _annotations + ..clear() + ..addEntries(snapshot.annotations.map((value) => MapEntry(value.id, value))); + _bookShelfRelations + ..clear() + ..addAll(snapshot.bookShelves); + _bookTagRelations + ..clear() + ..addAll(snapshot.bookTags); + if (snapshot.books.isEmpty) { + _series.clear(); + _readingGoals.clear(); + } + replaceBooksFromSync(snapshot.books); + } // ============================================================ // Getters for read access @@ -101,19 +146,15 @@ class DataStore extends ChangeNotifier { _isLoaded = false; if (wasLoaded) notifyListeners(); await _bookSubscription?.cancel(); + await _librarySubscription?.cancel(); + clear(); _bookRepository = repository; - _bookSubscription = repository.watchAll().listen( - replaceBooksFromSync, - onError: (Object error, StackTrace stackTrace) { - FlutterError.reportError( - FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus book repository'), - ); - }, - ); + _listenRepository(repository); } Future disposeBookRepository() async { await _bookSubscription?.cancel(); + await _librarySubscription?.cancel(); _bookSubscription = null; _bookRepository = null; } @@ -123,19 +164,17 @@ class DataStore extends ChangeNotifier { if (repository == null) { throw StateError('Book repository is not initialized'); } - return repository; + return repository is LibraryRepository ? (repository as LibraryRepository).scopedBooks : repository; } - bool isBookRepositoryCurrent(BookRepository repository) => identical(_bookRepository, repository); + bool isBookRepositoryCurrent(BookRepository repository) => + repository is EditableBookRepository ? repository.isCurrent : identical(_bookRepository, repository); void addBook(Book book) { - final repository = _bookRepository; - if (repository == null) { - throw StateError('Book repository is not initialized'); - } + final repository = requireBookRepository(); _books[book.id] = book; notifyListeners(); - unawaited(repository.upsert(book)); + _reportWrite(repository.upsert(book)); } Future addBookAndWait(Book book) async { @@ -152,26 +191,82 @@ class DataStore extends ChangeNotifier { } void updateBook(Book book) { - final repository = _bookRepository; - if (repository == null) { - throw StateError('Book repository is not initialized'); - } + final repository = requireBookRepository(); + final previous = _books[book.id]; _books[book.id] = book; notifyListeners(); - unawaited(repository.upsert(book)); + _reportWrite( + repository is EditableBookRepository && previous != null + ? repository.update(book, previous: previous) + : repository.upsert(book), + ); } - Future updateBookAndWait(Book book) async { - final repository = requireBookRepository(); - await addBookToRepositoryAndWait(repository, book); + void _reportWrite(Future operation) { + unawaited( + operation.catchError((Object error, StackTrace stack) { + FlutterError.reportError(FlutterErrorDetails(exception: error, stack: stack, library: 'papyrus library write')); + }), + ); } - void deleteBook(String id) { - final repository = _bookRepository; + Future updateBookAndWait(Book book, {Book? previous, BookRepository? repository}) async { + final target = repository ?? requireBookRepository(); + final baseline = previous ?? _books[book.id]; + if (target is EditableBookRepository && baseline != null) { + await target.update(book, previous: baseline); + final saved = await target.getById(book.id); + if (target.isCurrent && saved != null) { + _books[book.id] = saved; + notifyListeners(); + } + } else { + await addBookToRepositoryAndWait(target, book); + } + } + + Future _saveEntity( + EntityRepository? repository, + T value, + String id, + T? previous, + void Function(T) apply, + ) { if (repository == null) { - throw StateError('Book repository is not initialized'); + apply(value); + notifyListeners(); + return Future.value(); } - unawaited(repository.delete(id)); + final scope = requireBookRepository(); + return (() async { + await repository.upsert(value, previous: previous); + final saved = await repository.getById(id); + if (isBookRepositoryCurrent(scope) && saved != null) { + apply(saved); + notifyListeners(); + } + })(); + } + + Future _deleteEntity(EntityRepository? repository, String id, void Function() apply) { + if (repository == null) { + apply(); + notifyListeners(); + return Future.value(); + } + final scope = requireBookRepository(); + return (() async { + await repository.delete(id); + if (isBookRepositoryCurrent(scope)) { + apply(); + notifyListeners(); + } + })(); + } + + void deleteBook(String id) { + final repository = requireBookRepository(); + _reportWrite(repository.delete(id)); } Future deleteBookAndWait(String id) async { @@ -190,7 +285,7 @@ class DataStore extends ChangeNotifier { final mergedBooks = books .map((book) { final localBook = _books[book.id]; - if (book.coverMediaId == null && localBook?.coverMediaId != null) { + if (libraryRepository == null && book.coverMediaId == null && localBook?.coverMediaId != null) { // PowerSync can briefly emit the downloaded server row before its // pending local media-reference update is acknowledged. Keep the // established local reference through that transient null snapshot. @@ -224,21 +319,27 @@ class DataStore extends ChangeNotifier { return shelf.copyWith(bookCount: getBookCountForShelf(id), coverPreviews: getCoverPreviewsForShelf(id)); } - void addShelf(Shelf shelf) { - _shelves[shelf.id] = shelf; - notifyListeners(); - } - - void updateShelf(Shelf shelf) { - _shelves[shelf.id] = shelf; - notifyListeners(); - } - - void deleteShelf(String id) { - _shelves.remove(id); - _bookShelfRelations.removeWhere((r) => r.shelfId == id); - notifyListeners(); - } + Future addShelf(Shelf shelf, {Shelf? previous, EntityRepository? repository}) => _saveEntity( + repository ?? libraryRepository?.shelves, + shelf, + shelf.id, + previous, + (saved) => _shelves[shelf.id] = saved, + ); + + Future updateShelf(Shelf shelf, {Shelf? previous, EntityRepository? repository}) => _saveEntity( + repository ?? libraryRepository?.shelves, + shelf, + shelf.id, + previous ?? _shelves[shelf.id], + (saved) => _shelves[shelf.id] = saved, + ); + + Future deleteShelf(String id, {EntityRepository? repository}) => + _deleteEntity(repository ?? libraryRepository?.shelves, id, () { + _shelves.remove(id); + _bookShelfRelations.removeWhere((r) => r.shelfId == id); + }); /// Get all books in a shelf. List getBooksInShelf(String shelfId) { @@ -279,21 +380,22 @@ class DataStore extends ChangeNotifier { Tag? getTag(String id) => _tags[id]; - void addTag(Tag tag) { - _tags[tag.id] = tag; - notifyListeners(); - } + Future addTag(Tag tag, {Tag? previous, EntityRepository? repository}) => + _saveEntity(repository ?? libraryRepository?.tags, tag, tag.id, previous, (saved) => _tags[tag.id] = saved); - void updateTag(Tag tag) { - _tags[tag.id] = tag; - notifyListeners(); - } + Future updateTag(Tag tag, {Tag? previous, EntityRepository? repository}) => _saveEntity( + repository ?? libraryRepository?.tags, + tag, + tag.id, + previous ?? _tags[tag.id], + (saved) => _tags[tag.id] = saved, + ); - void deleteTag(String id) { - _tags.remove(id); - _bookTagRelations.removeWhere((r) => r.tagId == id); - notifyListeners(); - } + Future deleteTag(String id, {EntityRepository? repository}) => + _deleteEntity(repository ?? libraryRepository?.tags, id, () { + _tags.remove(id); + _bookTagRelations.removeWhere((r) => r.tagId == id); + }); /// Get all books with a tag. List getBooksWithTag(String tagId) { @@ -347,20 +449,31 @@ class DataStore extends ChangeNotifier { return _annotations.values.where((a) => a.bookId == bookId).toList(); } - void addAnnotation(Annotation annotation) { - _annotations[annotation.id] = annotation; - notifyListeners(); - } - - void updateAnnotation(Annotation annotation) { - _annotations[annotation.id] = annotation; - notifyListeners(); - } - - void deleteAnnotation(String id) { - _annotations.remove(id); - notifyListeners(); - } + Future addAnnotation(Annotation annotation, {Annotation? previous, EntityRepository? repository}) => + _saveEntity( + repository ?? libraryRepository?.annotations, + annotation, + annotation.id, + previous, + (saved) => _annotations[annotation.id] = saved, + ); + + Future updateAnnotation( + Annotation annotation, { + Annotation? previous, + EntityRepository? repository, + }) => _saveEntity( + repository ?? libraryRepository?.annotations, + annotation, + annotation.id, + previous ?? _annotations[annotation.id], + (saved) => _annotations[annotation.id] = saved, + ); + + Future deleteAnnotation(String id, {EntityRepository? repository}) => + _deleteEntity(repository ?? libraryRepository?.annotations, id, () { + _annotations.remove(id); + }); // ============================================================ // Note CRUD @@ -372,20 +485,21 @@ class DataStore extends ChangeNotifier { return _notes.values.where((n) => n.bookId == bookId).toList(); } - void addNote(Note note) { - _notes[note.id] = note; - notifyListeners(); - } + Future addNote(Note note, {Note? previous, EntityRepository? repository}) => + _saveEntity(repository ?? libraryRepository?.notes, note, note.id, previous, (saved) => _notes[note.id] = saved); - void updateNote(Note note) { - _notes[note.id] = note; - notifyListeners(); - } + Future updateNote(Note note, {Note? previous, EntityRepository? repository}) => _saveEntity( + repository ?? libraryRepository?.notes, + note, + note.id, + previous ?? _notes[note.id], + (saved) => _notes[note.id] = saved, + ); - void deleteNote(String id) { - _notes.remove(id); - notifyListeners(); - } + Future deleteNote(String id, {EntityRepository? repository}) => + _deleteEntity(repository ?? libraryRepository?.notes, id, () { + _notes.remove(id); + }); // ============================================================ // Bookmark CRUD @@ -480,19 +594,67 @@ class DataStore extends ChangeNotifier { // Book-Shelf Relations // ============================================================ - void addBookToShelf(String bookId, String shelfId) { - final exists = _bookShelfRelations.any((r) => r.bookId == bookId && r.shelfId == shelfId); - if (!exists) { - _bookShelfRelations.add(BookShelfRelation(bookId: bookId, shelfId: shelfId, addedAt: DateTime.now())); - notifyListeners(); + Future updateBookMemberships({ + required Set bookIds, + List? shelfIds, + List? tagIds, + Set? previousShelfIds, + Set? previousTagIds, + bool additive = false, + LibraryMembershipWriter? repository, + }) async { + final target = repository ?? libraryRepository?.memberships; + if (target != null) { + await target.updateMemberships( + bookIds: bookIds, + shelfIds: shelfIds, + tagIds: tagIds, + previousShelfIds: previousShelfIds, + previousTagIds: previousTagIds, + additive: additive, + ); + return; + } + for (final bookId in bookIds) { + if (shelfIds != null) { + if (!additive) { + for (final id in (previousShelfIds ?? getShelfIdsForBook(bookId).toSet()).difference(shelfIds.toSet())) { + await removeBookFromShelf(bookId, id); + } + } + for (final id in shelfIds) { + await addBookToShelf(bookId, id); + } + } + if (tagIds != null) { + if (!additive) { + for (final id in (previousTagIds ?? getTagIdsForBook(bookId).toSet()).difference(tagIds.toSet())) { + await removeTagFromBook(bookId, id); + } + } + for (final id in tagIds) { + await addTagToBook(bookId, id); + } + } } } - void removeBookFromShelf(String bookId, String shelfId) { - _bookShelfRelations.removeWhere((r) => r.bookId == bookId && r.shelfId == shelfId); - notifyListeners(); + Future addBookToShelf(String bookId, String shelfId) { + final exists = _bookShelfRelations.any((r) => r.bookId == bookId && r.shelfId == shelfId); + if (exists) return Future.value(); + final relation = BookShelfRelation(bookId: bookId, shelfId: shelfId, addedAt: DateTime.now().toUtc()); + return _saveEntity(libraryRepository?.bookShelves, relation, '$bookId:$shelfId', null, (saved) { + _bookShelfRelations.removeWhere((r) => r.bookId == bookId && r.shelfId == shelfId); + _bookShelfRelations.add(saved); + }); } + Future removeBookFromShelf(String bookId, String shelfId) => _deleteEntity( + libraryRepository?.bookShelves, + '$bookId:$shelfId', + () => _bookShelfRelations.removeWhere((r) => r.bookId == bookId && r.shelfId == shelfId), + ); + List getShelfIdsForBook(String bookId) { return _bookShelfRelations.where((r) => r.bookId == bookId).map((r) => r.shelfId).toList(); } @@ -506,18 +668,21 @@ class DataStore extends ChangeNotifier { // Book-Tag Relations // ============================================================ - void addTagToBook(String bookId, String tagId) { + Future addTagToBook(String bookId, String tagId) { final exists = _bookTagRelations.any((r) => r.bookId == bookId && r.tagId == tagId); - if (!exists) { - _bookTagRelations.add(BookTagRelation(bookId: bookId, tagId: tagId, createdAt: DateTime.now())); - notifyListeners(); - } - } - - void removeTagFromBook(String bookId, String tagId) { - _bookTagRelations.removeWhere((r) => r.bookId == bookId && r.tagId == tagId); - notifyListeners(); - } + if (exists) return Future.value(); + final relation = BookTagRelation(bookId: bookId, tagId: tagId, createdAt: DateTime.now().toUtc()); + return _saveEntity(libraryRepository?.bookTags, relation, '$bookId:$tagId', null, (saved) { + _bookTagRelations.removeWhere((r) => r.bookId == bookId && r.tagId == tagId); + _bookTagRelations.add(saved); + }); + } + + Future removeTagFromBook(String bookId, String tagId) => _deleteEntity( + libraryRepository?.bookTags, + '$bookId:$tagId', + () => _bookTagRelations.removeWhere((r) => r.bookId == bookId && r.tagId == tagId), + ); List getTagIdsForBook(String bookId) { return _bookTagRelations.where((r) => r.bookId == bookId).map((r) => r.tagId).toList(); diff --git a/app/lib/data/repositories/library_repository.dart b/app/lib/data/repositories/library_repository.dart new file mode 100644 index 0000000..c801bef --- /dev/null +++ b/app/lib/data/repositories/library_repository.dart @@ -0,0 +1,62 @@ +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/book_shelf_relation.dart'; +import 'package:papyrus/models/book_tag_relation.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/tag.dart'; + +abstract interface class EntityRepository { + Future getById(String id); + Future upsert(T value, {T? previous}); + Future delete(String id); +} + +abstract interface class EditableBookRepository implements BookRepository { + bool get isCurrent; + Future update(Book book, {required Book previous}); +} + +abstract interface class LibraryRepository { + Stream watchLibrary(); + EditableBookRepository get scopedBooks; + EntityRepository get shelves; + EntityRepository get tags; + EntityRepository get notes; + EntityRepository get annotations; + EntityRepository get bookShelves; + EntityRepository get bookTags; + LibraryMembershipWriter get memberships; +} + +abstract interface class LibraryMembershipWriter { + Future updateMemberships({ + required Set bookIds, + List? shelfIds, + List? tagIds, + Set? previousShelfIds, + Set? previousTagIds, + bool additive = false, + }); +} + +class LibrarySnapshot { + final List books; + final List shelves; + final List tags; + final List notes; + final List annotations; + final List bookShelves; + final List bookTags; + + const LibrarySnapshot({ + this.books = const [], + this.shelves = const [], + this.tags = const [], + this.notes = const [], + this.annotations = const [], + this.bookShelves = const [], + this.bookTags = const [], + }); +} diff --git a/app/lib/models/annotation.dart b/app/lib/models/annotation.dart index 60e6104..1f8d185 100644 --- a/app/lib/models/annotation.dart +++ b/app/lib/models/annotation.dart @@ -133,6 +133,7 @@ class Annotation { HighlightColor? color, BookLocation? location, String? note, + bool clearNote = false, DateTime? createdAt, DateTime? updatedAt, }) { @@ -142,7 +143,7 @@ class Annotation { selectedText: selectedText ?? this.selectedText, color: color ?? this.color, location: location ?? this.location, - note: note ?? this.note, + note: clearNote ? null : note ?? this.note, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/app/lib/models/book.dart b/app/lib/models/book.dart index c93b7e4..5050c94 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -178,11 +178,16 @@ class Book { String? coverUrl, bool clearCoverUrl = false, String? fileMediaId, + bool clearFileMediaId = false, String? coverMediaId, + bool clearCoverMediaId = false, String? filePath, BookFormat? fileFormat, + bool clearFileFormat = false, int? fileSize, + bool clearFileSize = false, String? fileHash, + bool clearFileHash = false, bool? isPhysical, String? physicalLocation, bool clearPhysicalLocation = false, @@ -192,21 +197,28 @@ class Book { bool clearLentAt = false, LibraryReadingStatus? readingStatus, int? currentPage, + bool clearCurrentPage = false, double? currentPosition, String? currentCfi, + bool clearCurrentCfi = false, bool? isFavorite, int? rating, bool clearRating = false, Map? customMetadata, + bool clearCustomMetadata = false, String? seriesId, + bool clearSeriesId = false, String? seriesName, bool clearSeriesName = false, double? seriesNumber, bool clearSeriesNumber = false, DateTime? addedAt, DateTime? startedAt, + bool clearStartedAt = false, DateTime? completedAt, + bool clearCompletedAt = false, DateTime? lastReadAt, + bool clearLastReadAt = false, }) { return Book( id: id ?? this.id, @@ -222,30 +234,30 @@ class Book { pageCount: clearPageCount ? null : (pageCount ?? this.pageCount), description: clearDescription ? null : (description ?? this.description), coverUrl: clearCoverUrl ? null : (coverUrl ?? this.coverUrl), - fileMediaId: fileMediaId ?? this.fileMediaId, - coverMediaId: coverMediaId ?? this.coverMediaId, + fileMediaId: clearFileMediaId ? null : fileMediaId ?? this.fileMediaId, + coverMediaId: clearCoverMediaId ? null : coverMediaId ?? this.coverMediaId, filePath: filePath ?? this.filePath, - fileFormat: fileFormat ?? this.fileFormat, - fileSize: fileSize ?? this.fileSize, - fileHash: fileHash ?? this.fileHash, + fileFormat: clearFileFormat ? null : fileFormat ?? this.fileFormat, + fileSize: clearFileSize ? null : fileSize ?? this.fileSize, + fileHash: clearFileHash ? null : fileHash ?? this.fileHash, isPhysical: isPhysical ?? this.isPhysical, physicalLocation: clearPhysicalLocation ? null : (physicalLocation ?? this.physicalLocation), lentTo: clearLentTo ? null : (lentTo ?? this.lentTo), lentAt: clearLentAt ? null : (lentAt ?? this.lentAt), readingStatus: readingStatus ?? this.readingStatus, - currentPage: currentPage ?? this.currentPage, + currentPage: clearCurrentPage ? null : currentPage ?? this.currentPage, currentPosition: currentPosition ?? this.currentPosition, - currentCfi: currentCfi ?? this.currentCfi, + currentCfi: clearCurrentCfi ? null : currentCfi ?? this.currentCfi, isFavorite: isFavorite ?? this.isFavorite, rating: clearRating ? null : (rating ?? this.rating), - customMetadata: customMetadata ?? this.customMetadata, - seriesId: seriesId ?? this.seriesId, + customMetadata: clearCustomMetadata ? null : customMetadata ?? this.customMetadata, + seriesId: clearSeriesId ? null : seriesId ?? this.seriesId, seriesName: clearSeriesName ? null : (seriesName ?? this.seriesName), seriesNumber: clearSeriesNumber ? null : (seriesNumber ?? this.seriesNumber), addedAt: addedAt ?? this.addedAt, - startedAt: startedAt ?? this.startedAt, - completedAt: completedAt ?? this.completedAt, - lastReadAt: lastReadAt ?? this.lastReadAt, + startedAt: clearStartedAt ? null : startedAt ?? this.startedAt, + completedAt: clearCompletedAt ? null : completedAt ?? this.completedAt, + lastReadAt: clearLastReadAt ? null : lastReadAt ?? this.lastReadAt, ); } diff --git a/app/lib/models/note.dart b/app/lib/models/note.dart index d039e34..ee722bd 100644 --- a/app/lib/models/note.dart +++ b/app/lib/models/note.dart @@ -57,6 +57,7 @@ class Note { String? title, String? content, BookLocation? location, + bool clearLocation = false, List? tags, bool? isPinned, DateTime? createdAt, @@ -67,7 +68,7 @@ class Note { bookId: bookId ?? this.bookId, title: title ?? this.title, content: content ?? this.content, - location: location ?? this.location, + location: clearLocation ? null : location ?? this.location, tags: tags ?? this.tags, isPinned: isPinned ?? this.isPinned, createdAt: createdAt ?? this.createdAt, diff --git a/app/lib/models/shelf.dart b/app/lib/models/shelf.dart index 229f7e7..f6d4747 100644 --- a/app/lib/models/shelf.dart +++ b/app/lib/models/shelf.dart @@ -13,6 +13,34 @@ class CoverPreview { const CoverPreview({required this.bookId, this.url, this.mediaId, required this.title}); } +/// Stored icon identity, including icons unavailable in this app's font bundle. +class ShelfIconDescriptor { + final int codePoint; + final String? fontFamily; + final String? fontPackage; + final bool matchTextDirection; + + const ShelfIconDescriptor({ + required this.codePoint, + this.fontFamily, + this.fontPackage, + this.matchTextDirection = false, + }); + + factory ShelfIconDescriptor.fromIcon(IconData icon) => ShelfIconDescriptor( + codePoint: icon.codePoint, + fontFamily: icon.fontFamily, + fontPackage: icon.fontPackage, + matchTextDirection: icon.matchTextDirection, + ); + + bool matches(IconData icon) => + codePoint == icon.codePoint && + fontFamily == icon.fontFamily && + fontPackage == icon.fontPackage && + matchTextDirection == icon.matchTextDirection; +} + /// Data model for a book shelf (collection). class Shelf { final String id; @@ -20,6 +48,7 @@ class Shelf { final String? description; final String? colorHex; final IconData? icon; + final ShelfIconDescriptor? _storedIconDescriptor; final String? parentShelfId; final bool isSmart; final String? smartQuery; @@ -37,6 +66,7 @@ class Shelf { this.description, this.colorHex, this.icon, + ShelfIconDescriptor? iconDescriptor, this.parentShelfId, this.isSmart = false, this.smartQuery, @@ -45,7 +75,7 @@ class Shelf { required this.updatedAt, this.bookCount = 0, this.coverPreviews = const [], - }); + }) : _storedIconDescriptor = iconDescriptor; /// Get display text for book count. String get bookCountLabel { @@ -67,6 +97,10 @@ class Shelf { /// Get default icon if none specified. IconData get displayIcon => icon ?? Icons.folder_outlined; + /// The original identity is independent of the icon used for rendering. + ShelfIconDescriptor? get iconDescriptor => + _storedIconDescriptor ?? (icon == null ? null : ShelfIconDescriptor.fromIcon(icon!)); + /// Create a copy with updated fields. Shelf copyWith({ String? id, @@ -74,10 +108,14 @@ class Shelf { String? description, bool clearDescription = false, String? colorHex, + bool clearColorHex = false, IconData? icon, + bool clearIcon = false, String? parentShelfId, + bool clearParentShelfId = false, bool? isSmart, String? smartQuery, + bool clearSmartQuery = false, int? sortOrder, DateTime? createdAt, DateTime? updatedAt, @@ -88,11 +126,13 @@ class Shelf { id: id ?? this.id, name: name ?? this.name, description: clearDescription ? null : description ?? this.description, - colorHex: colorHex ?? this.colorHex, - icon: icon ?? this.icon, - parentShelfId: parentShelfId ?? this.parentShelfId, + colorHex: clearColorHex ? null : colorHex ?? this.colorHex, + icon: clearIcon ? null : icon ?? this.icon, + // Editors also pass the unchanged display icon when editing other fields. + iconDescriptor: clearIcon || (icon != null && icon != this.icon) ? null : _storedIconDescriptor, + parentShelfId: clearParentShelfId ? null : parentShelfId ?? this.parentShelfId, isSmart: isSmart ?? this.isSmart, - smartQuery: smartQuery ?? this.smartQuery, + smartQuery: clearSmartQuery ? null : smartQuery ?? this.smartQuery, sortOrder: sortOrder ?? this.sortOrder, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, @@ -103,12 +143,16 @@ class Shelf { /// Convert to JSON for API/storage. Map toJson() { + final descriptor = iconDescriptor; return { 'id': id, 'name': name, 'description': description, 'color_hex': colorHex, - 'icon': icon?.codePoint, + 'icon': descriptor?.codePoint, + 'icon_font_family': descriptor?.fontFamily, + 'icon_font_package': descriptor?.fontPackage, + 'icon_match_text_direction': descriptor?.matchTextDirection ?? false, 'parent_shelf_id': parentShelfId, 'is_smart': isSmart, 'smart_query': smartQuery, @@ -120,12 +164,25 @@ class Shelf { /// Create from JSON. factory Shelf.fromJson(Map json) { + final codePoint = json['icon'] as int?; + final descriptor = codePoint == null + ? null + : ShelfIconDescriptor( + codePoint: codePoint, + // Legacy shelf JSON stored only the Material icon's code point. + fontFamily: json.containsKey('icon_font_family') + ? json['icon_font_family'] as String? + : Icons.folder_outlined.fontFamily, + fontPackage: json['icon_font_package'] as String?, + matchTextDirection: json['icon_match_text_direction'] as bool? ?? false, + ); return Shelf( id: json['id'] as String, name: json['name'] as String, description: json['description'] as String?, colorHex: json['color_hex'] as String?, - icon: _iconFromCodePoint(json['icon'] as int?), + icon: _iconFromDescriptor(descriptor), + iconDescriptor: descriptor, parentShelfId: json['parent_shelf_id'] as String?, isSmart: json['is_smart'] as bool? ?? false, smartQuery: json['smart_query'] as String?, @@ -135,14 +192,13 @@ class Shelf { ); } - /// Convert icon code point to IconData. - /// Returns null if codePoint is null. + /// Resolve a stored descriptor to a constant display icon. /// Only returns icons from availableIcons to allow tree shaking. - static IconData? _iconFromCodePoint(int? codePoint) { - if (codePoint == null) return null; + static IconData? _iconFromDescriptor(ShelfIconDescriptor? descriptor) { + if (descriptor == null) return null; // Look up in available icons only (for tree shaking compatibility) for (final icon in availableIcons) { - if (icon.codePoint == codePoint) return icon; + if (descriptor.matches(icon)) return icon; } // Return default icon if not found (instead of creating non-const IconData) return Icons.folder_outlined; diff --git a/app/lib/models/tag.dart b/app/lib/models/tag.dart index b8b826b..074524c 100644 --- a/app/lib/models/tag.dart +++ b/app/lib/models/tag.dart @@ -21,12 +21,19 @@ class Tag { } /// Create a copy with updated fields. - Tag copyWith({String? id, String? name, String? colorHex, String? description, DateTime? createdAt}) { + Tag copyWith({ + String? id, + String? name, + String? colorHex, + String? description, + bool clearDescription = false, + DateTime? createdAt, + }) { return Tag( id: id ?? this.id, name: name ?? this.name, colorHex: colorHex ?? this.colorHex, - description: description ?? this.description, + description: clearDescription ? null : description ?? this.description, createdAt: createdAt ?? this.createdAt, ); } diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index 51ab2ee..fc3cb3f 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -336,19 +336,35 @@ class _AnnotationsPageState extends State { } void _onEditAnnotationNote(AnnotationsProvider provider, Annotation annotation) async { - final note = await AnnotationNoteSheet.show(context, annotation: annotation); + final repository = context.read().libraryRepository?.annotations; + await AnnotationNoteSheet.show( + context, + annotation: annotation, + onSave: (note) => provider.updateAnnotationNote( + annotation.id, + note.isEmpty ? null : note, + previous: annotation, + repository: repository, + ), + ); if (!mounted) return; - - if (note != null) { - provider.updateAnnotationNote(annotation.id, note.isEmpty ? null : note); - } } void _onDeleteAnnotation(AnnotationsProvider provider, Annotation annotation) async { final bookTitle = provider.getBookTitle(annotation.bookId); + final repository = context.read().libraryRepository?.annotations; final confirmed = await DeleteAnnotationDialog.show(context, annotation: annotation, bookTitle: bookTitle); if (confirmed && mounted) { - provider.deleteAnnotation(annotation.id); + try { + await provider.deleteAnnotation(annotation.id, repository: repository); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + } + return; + } } } } diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index b9233c9..4db26ed 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -505,10 +505,14 @@ class _BookDetailsPageState extends State with SingleTickerProv void _onAddNote() async { if (_provider.book == null) return; - final note = await NoteDialog.show(context, bookId: _provider.book!.id); + final repository = context.read().libraryRepository?.notes; + final note = await NoteDialog.show( + context, + bookId: _provider.book!.id, + onSave: (note) => _provider.addNote(note, repository: repository), + ); if (note != null && mounted) { - _provider.addNote(note); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Note added'))); } } @@ -531,10 +535,14 @@ class _BookDetailsPageState extends State with SingleTickerProv void _onAddAnnotation() async { if (_provider.book == null) return; - final annotation = await AnnotationDialog.show(context, bookId: _provider.book!.id); + final repository = context.read().libraryRepository?.annotations; + final annotation = await AnnotationDialog.show( + context, + bookId: _provider.book!.id, + onSave: (annotation) => _provider.addAnnotation(annotation, repository: repository), + ); if (annotation != null && mounted) { - _provider.addAnnotation(annotation); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Annotation added'))); } } @@ -555,19 +563,35 @@ class _BookDetailsPageState extends State with SingleTickerProv void _onEditNote(Note note) async { if (_provider.book == null) return; - final updatedNote = await NoteDialog.show(context, bookId: _provider.book!.id, existingNote: note); + final repository = context.read().libraryRepository?.notes; + final updatedNote = 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) { - _provider.updateNote(note.id, updatedNote); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Note updated'))); } } void _onDeleteNote(Note note) async { + final repository = context.read().libraryRepository?.notes; final confirmed = await DeleteNoteDialog.show(context, note: note); if (confirmed && mounted) { - _provider.deleteNote(note.id); + try { + await _provider.deleteNote(note.id, repository: repository); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + } + return; + } + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Note deleted'))); } } @@ -586,22 +610,38 @@ class _BookDetailsPageState extends State with SingleTickerProv } void _onEditAnnotationNote(Annotation annotation) async { - final note = await annotation_sheets.AnnotationNoteSheet.show(context, annotation: annotation); + final repository = context.read().libraryRepository?.annotations; + await annotation_sheets.AnnotationNoteSheet.show( + context, + annotation: annotation, + onSave: (note) => _provider.updateAnnotationNote( + annotation.id, + note.isEmpty ? null : note, + previous: annotation, + repository: repository, + ), + ); if (!mounted) return; - - if (note != null) { - _provider.updateAnnotationNote(annotation.id, note.isEmpty ? null : note); - } } void _onDeleteAnnotation(Annotation annotation) async { + final repository = context.read().libraryRepository?.annotations; final confirmed = await annotation_sheets.DeleteAnnotationDialog.show( context, annotation: annotation, bookTitle: _provider.book?.title ?? '', ); if (confirmed && mounted) { - _provider.deleteAnnotation(annotation.id); + try { + await _provider.deleteAnnotation(annotation.id, repository: repository); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + } + return; + } } } diff --git a/app/lib/pages/notes_page.dart b/app/lib/pages/notes_page.dart index 715c063..6afbfe4 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -313,19 +313,31 @@ class _NotesPageState extends State { } void _showNoteActions(BuildContext context, NotesProvider provider, Note note) async { + final repository = context.read().libraryRepository?.notes; final action = await NoteActionSheet.show(context, note: note); if (!mounted || action == null) return; switch (action) { case NoteAction.edit: - final updatedNote = await NoteDialog.show(this.context, bookId: note.bookId, existingNote: note); - if (updatedNote != null && mounted) { - provider.updateNote(updatedNote); - } + await NoteDialog.show( + this.context, + bookId: note.bookId, + existingNote: note, + onSave: (updated) => provider.updateNote(updated, previous: note, repository: repository), + ); case NoteAction.delete: final confirmed = await DeleteNoteDialog.show(this.context, note: note); if (confirmed && mounted) { - provider.deleteNote(note.id); + try { + await provider.deleteNote(note.id, repository: repository); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of( + this.context, + ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + } + return; + } } } } diff --git a/app/lib/pages/shelf_contents_page.dart b/app/lib/pages/shelf_contents_page.dart index 1829dc4..a0aed1e 100644 --- a/app/lib/pages/shelf_contents_page.dart +++ b/app/lib/pages/shelf_contents_page.dart @@ -56,11 +56,12 @@ class ShelfContentsPage extends StatelessWidget { } void _editShelf(BuildContext context, DataStore dataStore, Shelf shelf) { + final repository = dataStore.libraryRepository?.shelves; AddShelfSheet.show( context, shelf: shelf, - onSave: (name, description, colorHex, icon) { - dataStore.updateShelf( + onSave: (name, description, colorHex, icon) async { + await dataStore.updateShelf( shelf.copyWith( name: name, description: description, @@ -69,6 +70,8 @@ class ShelfContentsPage extends StatelessWidget { icon: icon, updatedAt: DateTime.now(), ), + previous: shelf, + repository: repository, ); }, ); diff --git a/app/lib/pages/shelves_page.dart b/app/lib/pages/shelves_page.dart index 083fa14..dca49e2 100644 --- a/app/lib/pages/shelves_page.dart +++ b/app/lib/pages/shelves_page.dart @@ -323,20 +323,30 @@ class _ShelvesPageState extends State { // ============================================================================ void _showAddShelfSheet(BuildContext context) { + final repository = context.read().libraryRepository?.shelves; AddShelfSheet.show( context, - onSave: (name, description, colorHex, icon) { - _provider.createShelf(name: name, description: description, colorHex: colorHex, icon: icon); + onSave: (name, description, colorHex, icon) async { + await _provider.createShelf( + name: name, + description: description, + colorHex: colorHex, + icon: icon, + repository: repository, + ); }, ); } void _showEditShelfSheet(BuildContext context, ShelfData shelf) { + final repository = context.read().libraryRepository?.shelves; AddShelfSheet.show( context, shelf: shelf, - onSave: (name, description, colorHex, icon) { - _provider.updateShelf( + onSave: (name, description, colorHex, icon) async { + await _provider.updateShelf( + previous: shelf, + repository: repository, shelfId: shelf.id, name: name, description: description, @@ -402,6 +412,7 @@ class _ShelvesPageState extends State { } void _confirmDeleteShelf(BuildContext context, ShelfData shelf) { + final repository = context.read().libraryRepository?.shelves; final colorScheme = Theme.of(context).colorScheme; showDialog( @@ -412,9 +423,17 @@ class _ShelvesPageState extends State { actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), FilledButton( - onPressed: () { - Navigator.of(context).pop(); - _provider.deleteShelf(shelf.id); + onPressed: () async { + try { + await _provider.deleteShelf(shelf.id, repository: repository); + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not delete shelf. Please try again.'))); + } + } }, style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: const Text('Delete'), diff --git a/app/lib/powersync/library_database.dart b/app/lib/powersync/library_database.dart new file mode 100644 index 0000000..780f210 --- /dev/null +++ b/app/lib/powersync/library_database.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; + +import 'package:papyrus/data/repositories/library_repository.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; +import 'package:papyrus/powersync/powersync_book_mapper.dart'; +import 'package:powersync/powersync.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// A handle bound to one opened library, invalidated before a profile switch. +class LibraryDatabase implements LibraryMembershipWriter { + final PowerSyncDatabase database; + final Future Function() onWrite; + bool active = true; + + LibraryDatabase(this.database, this.onWrite); + + late final shelves = SqlEntityRepository(this, shelfRowMapper); + late final tags = SqlEntityRepository(this, tagRowMapper); + late final notes = SqlEntityRepository(this, noteRowMapper); + late final annotations = SqlEntityRepository(this, annotationRowMapper); + late final bookShelves = SqlEntityRepository(this, bookShelfRowMapper); + late final bookTags = SqlEntityRepository(this, bookTagRowMapper); + late final books = ScopedBooks(this); + + void checkActive() { + if (!active) throw StateError('The library changed. Reopen this item before saving.'); + } + + Future write(Future Function(SqliteWriteContext) action) async { + checkActive(); + await database.writeTransaction((tx) async { + checkActive(); + await action(tx); + }); + if (active) await onWrite(); + } + + Future upsert(String table, Map row, {Map? previous}) => + write((tx) => upsertRow(tx, table, row, previous: previous)); + + Future upsertRow( + SqliteWriteContext tx, + String table, + Map row, { + Map? previous, + }) async { + final id = row['id']; + final existing = await tx.getOptional('SELECT * FROM $table WHERE id = ?', [id]); + if (existing == null) { + if (previous != null) return; + await _validateReferences(tx, table, row); + await tx.execute( + 'INSERT INTO $table (${row.keys.join(', ')}) VALUES (${List.filled(row.length, '?').join(', ')})', + row.values.toList(), + ); + return; + } + + final baseline = previous ?? Map.from(existing); + final changes = Map.fromEntries( + row.entries.where( + (entry) => + !['id', 'updated_at', 'created_at', 'added_at'].contains(entry.key) && + !_sameValue(entry.key, entry.value, baseline[entry.key]), + ), + ); + if (changes.isEmpty) return; + if (row.containsKey('updated_at')) changes['updated_at'] = DateTime.now().toUtc().toIso8601String(); + await _validateReferences(tx, table, {...existing, ...changes}); + await tx.execute('UPDATE $table SET ${changes.keys.map((key) => '$key = ?').join(', ')} WHERE id = ?', [ + ...changes.values, + id, + ]); + } + + Future _validateReferences(SqliteReadContext tx, String table, Map row) async { + for (final reference in {'book_id': 'books', 'shelf_id': 'shelves', 'tag_id': 'tags'}.entries) { + final id = row[reference.key]; + if (id != null && await tx.getOptional('SELECT id FROM ${reference.value} WHERE id = ?', [id]) == null) { + throw StateError('The referenced ${reference.value} record no longer exists.'); + } + } + if (table != 'shelves') return; + final visited = {row['id']}; + var parent = row['parent_shelf_id']; + while (parent != null) { + if (!visited.add(parent)) throw StateError('A shelf cannot contain itself.'); + final ancestor = await tx.getOptional('SELECT parent_shelf_id FROM shelves WHERE id = ?', [parent]); + if (ancestor == null) throw StateError('The parent shelf no longer exists.'); + parent = ancestor['parent_shelf_id']; + } + } + + Future delete(String table, String id) => write((tx) async { + if (table == 'books') { + for (final dependent in ['notes', 'annotations', 'book_shelves', 'book_tags']) { + await tx.execute('DELETE FROM $dependent WHERE book_id = ?', [id]); + } + } else if (table == 'shelves') { + await tx.execute('DELETE FROM book_shelves WHERE shelf_id = ?', [id]); + await tx.execute('UPDATE shelves SET parent_shelf_id = NULL, updated_at = ? WHERE parent_shelf_id = ?', [ + DateTime.now().toUtc().toIso8601String(), + id, + ]); + } else if (table == 'tags') { + await tx.execute('DELETE FROM book_tags WHERE tag_id = ?', [id]); + } + await tx.execute('DELETE FROM $table WHERE id = ?', [id]); + }); + + @override + Future updateMemberships({ + required Set bookIds, + List? shelfIds, + List? tagIds, + Set? previousShelfIds, + Set? previousTagIds, + bool additive = false, + }) => write((tx) async { + Future update(String table, String field, List? selected, Set? baseline) async { + if (selected == null) return; + final wanted = selected.toSet(); + for (final bookId in bookIds) { + final rows = await tx.getAll('SELECT $field FROM $table WHERE book_id = ?', [bookId]); + final current = rows.map((row) => row[field] as String).toSet(); + final original = baseline ?? current; + if (!additive) { + for (final removed in original.difference(wanted)) { + await tx.execute('DELETE FROM $table WHERE id = ?', ['$bookId:$removed']); + } + } + final additions = additive ? wanted : wanted.difference(original); + for (final added in additions.difference(current)) { + await upsertRow(tx, table, { + 'id': '$bookId:$added', + 'book_id': bookId, + field: added, + table == 'book_shelves' ? 'added_at' : 'created_at': DateTime.now().toUtc().toIso8601String(), + if (table == 'book_shelves') 'sort_order': 0, + }); + } + } + } + + await update('book_shelves', 'shelf_id', shelfIds, previousShelfIds); + await update('book_tags', 'tag_id', tagIds, previousTagIds); + }); + + Future snapshot() => database.readTransaction((tx) async { + Future> rows(LibraryRowMapper mapper) async => (await tx.getAll( + 'SELECT * FROM ${mapper.table}', + )).map((row) => mapper.fromRow(Map.from(row))).toList(); + return LibrarySnapshot( + books: (await tx.getAll( + 'SELECT * FROM books ORDER BY added_at DESC', + )).map((row) => PowerSyncBookMapper.fromRow(Map.from(row))).toList(), + shelves: await rows(shelfRowMapper), + tags: await rows(tagRowMapper), + notes: await rows(noteRowMapper), + annotations: await rows(annotationRowMapper), + bookShelves: await rows(bookShelfRowMapper), + bookTags: await rows(bookTagRowMapper), + ); + }); + + /// Expands legacy local metadata once without clearing data or the CRUD queue. + Future migrateLegacyBooks() async { + await database.writeTransaction((tx) async { + if (await tx.getOptional("SELECT id FROM library_migrations WHERE id = 'book-fields-v1'") != null) return; + final storageTable = database.schema.tables.singleWhere((table) => table.name == 'books').internalName; + for (final raw in await tx.getAll('SELECT id, data FROM $storageTable')) { + final row = Map.from(jsonDecode(raw['data'] as String) as Map); + final encoded = row['custom_metadata']; + if (encoded is! String) continue; + final metadata = jsonDecode(encoded); + if (metadata is! Map) continue; + final changes = {}; + for (final entry in metadata.entries) { + if (row.containsKey(entry.key)) continue; + final value = _legacyPromotedValue(entry.key, entry.value); + if (value != null) changes[entry.key] = value; + } + if (changes.isNotEmpty) { + await tx.execute('UPDATE books SET ${changes.keys.map((key) => '$key = ?').join(', ')} WHERE id = ?', [ + ...changes.values, + raw['id'], + ]); + } + } + await tx.execute("INSERT INTO library_migrations (id, version) VALUES ('book-fields-v1', 1)"); + }); + } +} + +Object? _legacyPromotedValue(String key, Object? value) { + if (value == null) return null; + if (['publication_date', 'lent_at', 'started_at', 'completed_at', 'last_read_at'].contains(key)) { + final date = value is String ? DateTime.tryParse(value)?.toUtc() : null; + return date != null && date.year >= 1 && date.year <= 9999 ? date.toIso8601String() : null; + } + if (['file_format', 'file_hash', 'physical_location', 'lent_to', 'series_id', 'series_name'].contains(key)) { + return value is String ? value : null; + } + if (key == 'file_size') return value is int && value >= 0 && value.bitLength <= 63 ? value : null; + if (key == 'series_number') return value is num && value.isFinite ? value.toDouble() : null; + if (key == 'is_physical') { + if (value is bool) return value ? 1 : 0; + if (value == 0 || value == 1) return value; + } + return null; +} + +bool _sameValue(String key, Object? a, Object? b) { + if (a == b) return true; + if ((key.endsWith('_at') || key == 'publication_date') && a is String && b is String) { + final first = DateTime.tryParse(a); + final second = DateTime.tryParse(b); + if (first != null && second != null) return first.isAtSameMomentAs(second); + } + return false; +} + +class SqlEntityRepository implements EntityRepository { + final LibraryDatabase library; + final LibraryRowMapper mapper; + SqlEntityRepository(this.library, this.mapper); + + @override + Future getById(String id) async { + library.checkActive(); + final row = await library.database.getOptional('SELECT * FROM ${mapper.table} WHERE id = ?', [id]); + return row == null ? null : mapper.fromRow(Map.from(row)); + } + + @override + Future upsert(T value, {T? previous}) => + library.upsert(mapper.table, mapper.toRow(value), previous: previous == null ? null : mapper.toRow(previous)); + + @override + Future delete(String id) => library.delete(mapper.table, id); +} + +class ScopedBooks implements EditableBookRepository { + final LibraryDatabase library; + ScopedBooks(this.library); + @override + bool get isCurrent => library.active; + @override + Future getById(String id) async { + library.checkActive(); + final row = await library.database.getOptional('SELECT * FROM books WHERE id = ?', [id]); + return row == null ? null : PowerSyncBookMapper.fromRow(Map.from(row)); + } + + @override + Stream> watchAll() => library.database + .watch('SELECT * FROM books ORDER BY added_at DESC') + .map((rows) => rows.map((row) => PowerSyncBookMapper.fromRow(Map.from(row))).toList()); + @override + Future upsert(Book book) => library.upsert('books', PowerSyncBookMapper.toRow(book)); + @override + Future update(Book book, {required Book previous}) => + library.upsert('books', PowerSyncBookMapper.toRow(book), previous: PowerSyncBookMapper.toRow(previous)); + @override + Future delete(String id) => library.delete('books', id); +} diff --git a/app/lib/powersync/library_row_mapper.dart b/app/lib/powersync/library_row_mapper.dart new file mode 100644 index 0000000..9a1956a --- /dev/null +++ b/app/lib/powersync/library_row_mapper.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book_shelf_relation.dart'; +import 'package:papyrus/models/book_tag_relation.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/tag.dart'; + +class LibraryRowMapper { + final String table; + final Map Function(T) toRow; + final T Function(Map) fromRow; + + const LibraryRowMapper(this.table, this.toRow, this.fromRow); +} + +const libraryTableNames = ['books', 'shelves', 'tags', 'notes', 'annotations', 'book_shelves', 'book_tags']; + +final shelfRowMapper = LibraryRowMapper('shelves', (shelf) { + final row = Map.from(shelf.toJson()); + row['icon_code_point'] = row.remove('icon'); + return encodeLibraryRow(row); +}, (row) => Shelf.fromJson({...decodeLibraryRow(row), 'icon': row['icon_code_point']})); + +final tagRowMapper = LibraryRowMapper( + 'tags', + (value) => encodeLibraryRow(value.toJson()), + (row) => Tag.fromJson(decodeLibraryRow(row)), +); + +Map _locatedRow(Map json, BookLocation? location) { + final row = Map.from(json); + for (final key in ['chapter', 'chapter_title', 'page_number', 'percentage']) { + row.remove(key); + } + row['location'] = location == null + ? null + : { + 'chapter': location.chapter, + 'chapter_title': location.chapterTitle, + 'page_number': location.pageNumber, + 'percentage': location.percentage, + }; + return encodeLibraryRow(row); +} + +Map _locatedJson(Map row) { + final json = decodeLibraryRow(row); + final location = json.remove('location'); + if (location is Map) json.addAll(Map.from(location)); + return json; +} + +final noteRowMapper = LibraryRowMapper( + 'notes', + (value) => _locatedRow(value.toJson(), value.location), + (row) => Note.fromJson(_locatedJson(row)), +); +final annotationRowMapper = LibraryRowMapper( + 'annotations', + (value) => _locatedRow(value.toJson(), value.location), + (row) => Annotation.fromJson(_locatedJson(row)), +); +final bookShelfRowMapper = LibraryRowMapper( + 'book_shelves', + (value) => encodeLibraryRow({'id': '${value.bookId}:${value.shelfId}', ...value.toJson()}), + BookShelfRelation.fromJson, +); +final bookTagRowMapper = LibraryRowMapper( + 'book_tags', + (value) => encodeLibraryRow({'id': '${value.bookId}:${value.tagId}', ...value.toJson()}), + BookTagRelation.fromJson, +); + +Map encodeLibraryRow(Map row) => row.map((key, value) { + if (value is bool) return MapEntry(key, value ? 1 : 0); + if (value is List || value is Map) return MapEntry(key, jsonEncode(value)); + return MapEntry(key, value); +}); + +Map decodeLibraryRow(Map row) => row.map((key, value) { + if (['is_smart', 'is_pinned', 'icon_match_text_direction'].contains(key)) { + return MapEntry(key, value == true || value == 1); + } + if (['tags', 'location'].contains(key) && value is String) { + return MapEntry(key, jsonDecode(value)); + } + return MapEntry(key, value); +}); diff --git a/app/lib/powersync/papyrus_powersync_connector.dart b/app/lib/powersync/papyrus_powersync_connector.dart index de0ddc5..6c4de57 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -2,6 +2,7 @@ import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/powersync/powersync_book_mapper.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; import 'package:powersync/powersync.dart'; class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { @@ -62,6 +63,8 @@ Map powerSyncCrudEntryToJson(CrudEntry entry) { if (entry.table == 'books') { json['data'] = PowerSyncBookMapper.decodeUploadData(entry.opData); + } else if (entry.opData != null) { + json['data'] = decodeLibraryRow(entry.opData!); } return json; diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index 84a77ee..88d57e7 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -24,6 +24,20 @@ const _bookColumns = [ Column.text('custom_metadata'), Column.text('added_at'), Column.text('updated_at'), + Column.text('publication_date'), + Column.text('file_format'), + Column.integer('file_size'), + Column.text('file_hash'), + Column.integer('is_physical'), + Column.text('physical_location'), + Column.text('lent_to'), + Column.text('lent_at'), + Column.text('series_id'), + Column.text('series_name'), + Column.real('series_number'), + Column.text('started_at'), + Column.text('completed_at'), + Column.text('last_read_at'), ]; const _bookIndexes = [ @@ -31,9 +45,90 @@ const _bookIndexes = [ Index('books_title', [IndexedColumn('title')]), ]; -const papyrusAccountSchema = Schema([Table('books', _bookColumns, indexes: _bookIndexes)]); +const _shelvesColumns = [ + Column.text('owner_user_id'), + Column.text('name'), + Column.text('description'), + Column.text('color_hex'), + Column.integer('icon_code_point'), + Column.text('icon_font_family'), + Column.text('icon_font_package'), + Column.integer('icon_match_text_direction'), + Column.text('parent_shelf_id'), + Column.integer('is_smart'), + Column.text('smart_query'), + Column.integer('sort_order'), + Column.text('created_at'), + Column.text('updated_at'), +]; + +const _tagsColumns = [ + Column.text('owner_user_id'), + Column.text('name'), + Column.text('color_hex'), + Column.text('description'), + Column.text('created_at'), +]; + +const _notesColumns = [ + Column.text('owner_user_id'), + Column.text('book_id'), + Column.text('title'), + Column.text('content'), + Column.text('location'), + Column.text('tags'), + Column.integer('is_pinned'), + Column.text('created_at'), + Column.text('updated_at'), +]; + +const _annotationsColumns = [ + Column.text('owner_user_id'), + Column.text('book_id'), + Column.text('selected_text'), + Column.text('color'), + Column.text('location'), + Column.text('note'), + Column.text('created_at'), + Column.text('updated_at'), +]; + +const _bookShelvesColumns = [ + Column.text('owner_user_id'), + Column.text('book_id'), + Column.text('shelf_id'), + Column.text('added_at'), + Column.integer('sort_order'), +]; + +const _bookTagsColumns = [ + Column.text('owner_user_id'), + Column.text('book_id'), + Column.text('tag_id'), + Column.text('created_at'), +]; + +const papyrusAccountSchema = Schema([ + Table('books', _bookColumns, indexes: _bookIndexes), + Table('shelves', _shelvesColumns), + Table('tags', _tagsColumns), + Table('notes', _notesColumns), + Table('annotations', _annotationsColumns), + Table('book_shelves', _bookShelvesColumns), + Table('book_tags', _bookTagsColumns), + Table.localOnly('library_migrations', [Column.integer('version')]), +]); -const papyrusGuestSchema = Schema([Table.localOnly('books', _bookColumns, indexes: _bookIndexes)]); +const papyrusGuestSchema = Schema([ + Table.localOnly('books', _bookColumns, indexes: _bookIndexes), + Table.localOnly('shelves', _shelvesColumns), + Table.localOnly('tags', _tagsColumns), + Table.localOnly('notes', _notesColumns), + Table.localOnly('annotations', _annotationsColumns), + Table.localOnly('book_shelves', _bookShelvesColumns), + Table.localOnly('book_tags', _bookTagsColumns), + Table.localOnly('library_migrations', [Column.integer('version')]), +]); @Deprecated('Use papyrusAccountSchema') const papyrusPowerSyncSchema = papyrusAccountSchema; diff --git a/app/lib/powersync/powersync_book_mapper.dart b/app/lib/powersync/powersync_book_mapper.dart index 952066a..5628de3 100644 --- a/app/lib/powersync/powersync_book_mapper.dart +++ b/app/lib/powersync/powersync_book_mapper.dart @@ -26,11 +26,26 @@ const syncedBookColumns = [ 'custom_metadata', 'added_at', 'updated_at', + 'publication_date', + 'file_format', + 'file_size', + 'file_hash', + 'is_physical', + 'physical_location', + 'lent_to', + 'lent_at', + 'series_id', + 'series_name', + 'series_number', + 'started_at', + 'completed_at', + 'last_read_at', ]; class PowerSyncBookMapper { static Book fromRow(Map row) { final metadata = _decodeObject(row['custom_metadata']); + Object? field(String key) => row.containsKey(key) ? row[key] : metadata[key]; return Book( id: row['id'] as String, @@ -40,7 +55,7 @@ class PowerSyncBookMapper { coAuthors: _decodeStringList(row['co_authors']), isbn: row['isbn'] as String?, isbn13: row['isbn13'] as String?, - publicationDate: _parseDate(metadata['publication_date']), + publicationDate: _parseDate(field('publication_date')), publisher: row['publisher'] as String?, language: row['language'] as String?, pageCount: _toInt(row['page_count']), @@ -48,13 +63,13 @@ class PowerSyncBookMapper { coverUrl: row['cover_image_url'] as String?, fileMediaId: row['file_media_id'] as String?, coverMediaId: row['cover_media_id'] as String?, - fileFormat: _bookFormat(metadata['file_format']), - fileSize: _toInt(metadata['file_size']), - fileHash: metadata['file_hash'] as String?, - isPhysical: _toBool(metadata['is_physical']), - physicalLocation: metadata['physical_location'] as String?, - lentTo: metadata['lent_to'] as String?, - lentAt: _parseDate(metadata['lent_at']), + fileFormat: _bookFormat(field('file_format')), + fileSize: _toInt(field('file_size')), + fileHash: field('file_hash') as String?, + isPhysical: _toBool(field('is_physical')), + physicalLocation: field('physical_location') as String?, + lentTo: field('lent_to') as String?, + lentAt: _parseDate(field('lent_at')), readingStatus: _readingStatus(row['reading_status']), currentPage: _toInt(row['current_page']), currentPosition: _toDouble(row['current_position']) ?? 0.0, @@ -62,34 +77,18 @@ class PowerSyncBookMapper { isFavorite: _toBool(row['is_favorite']), rating: _toInt(row['rating']), customMetadata: _decodeNestedMetadata(metadata['custom_metadata']), - seriesId: metadata['series_id'] as String?, - seriesName: metadata['series_name'] as String?, - seriesNumber: _toDouble(metadata['series_number']), + seriesId: field('series_id') as String?, + seriesName: field('series_name') as String?, + seriesNumber: _toDouble(field('series_number')), addedAt: _parseDate(row['added_at']) ?? DateTime.now(), - startedAt: _parseDate(metadata['started_at']), - completedAt: _parseDate(metadata['completed_at']), - lastReadAt: _parseDate(metadata['last_read_at']), + startedAt: _parseDate(field('started_at')), + completedAt: _parseDate(field('completed_at')), + lastReadAt: _parseDate(field('last_read_at')), ); } static Map toRow(Book book) { - final metadata = { - if (book.publicationDate != null) 'publication_date': book.publicationDate!.toIso8601String(), - if (book.fileFormat != null) 'file_format': book.fileFormat!.name, - if (book.fileSize != null) 'file_size': book.fileSize, - if (book.fileHash != null) 'file_hash': book.fileHash, - 'is_physical': book.isPhysical, - if (book.physicalLocation != null) 'physical_location': book.physicalLocation, - if (book.lentTo != null) 'lent_to': book.lentTo, - if (book.lentAt != null) 'lent_at': book.lentAt!.toIso8601String(), - if (book.customMetadata != null) 'custom_metadata': book.customMetadata, - if (book.seriesId != null) 'series_id': book.seriesId, - if (book.seriesName != null) 'series_name': book.seriesName, - if (book.seriesNumber != null) 'series_number': book.seriesNumber, - if (book.startedAt != null) 'started_at': book.startedAt!.toIso8601String(), - if (book.completedAt != null) 'completed_at': book.completedAt!.toIso8601String(), - if (book.lastReadAt != null) 'last_read_at': book.lastReadAt!.toIso8601String(), - }; + final metadata = {'custom_metadata': book.customMetadata}; final now = DateTime.now().toIso8601String(); return { @@ -116,6 +115,20 @@ class PowerSyncBookMapper { 'custom_metadata': jsonEncode(metadata), 'added_at': book.addedAt.toIso8601String(), 'updated_at': now, + 'publication_date': book.publicationDate?.toUtc().toIso8601String(), + 'file_format': book.fileFormat?.name, + 'file_size': book.fileSize, + 'file_hash': book.fileHash, + 'is_physical': book.isPhysical ? 1 : 0, + 'physical_location': book.physicalLocation, + 'lent_to': book.lentTo, + 'lent_at': book.lentAt?.toUtc().toIso8601String(), + 'series_id': book.seriesId, + 'series_name': book.seriesName, + 'series_number': book.seriesNumber, + 'started_at': book.startedAt?.toUtc().toIso8601String(), + 'completed_at': book.completedAt?.toUtc().toIso8601String(), + 'last_read_at': book.lastReadAt?.toUtc().toIso8601String(), }; } @@ -125,8 +138,12 @@ class PowerSyncBookMapper { } final decoded = Map.from(data); - decoded['co_authors'] = _decodeStringList(decoded['co_authors']); - decoded['custom_metadata'] = _decodeObject(decoded['custom_metadata']); + if (decoded.containsKey('co_authors') && decoded['co_authors'] != null) { + decoded['co_authors'] = _decodeStringList(decoded['co_authors']); + } + if (decoded.containsKey('custom_metadata') && decoded['custom_metadata'] != null) { + decoded['custom_metadata'] = _decodeObject(decoded['custom_metadata']); + } return decoded; } @@ -154,7 +171,8 @@ WHERE id = ? } static String? _remoteCoverUrl(String? coverUrl) { - if (coverUrl == null || coverUrl.startsWith('data:')) { + final uri = coverUrl == null ? null : Uri.tryParse(coverUrl); + if (uri == null || !{'http', 'https'}.contains(uri.scheme) || uri.host.isEmpty) { return null; } diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index 7d71c33..bbb2cab 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -3,6 +3,15 @@ import 'dart:convert'; import 'package:flutter/foundation.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/book_shelf_relation.dart'; +import 'package:papyrus/models/book_tag_relation.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/powersync/library_database.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/powersync/book_metadata_sync_state.dart'; import 'package:papyrus/powersync/papyrus_schema.dart'; @@ -36,7 +45,7 @@ class SyncStateRevisionCoordinator { void invalidate() => _revision++; } -class PapyrusPowerSyncService implements BookRepository { +class PapyrusPowerSyncService implements BookRepository, LibraryRepository { final PowerSyncConnectorFactory connectorFactory; final LibraryDatabasePathResolver? pathResolver; final bool connectAuthenticated; @@ -47,6 +56,40 @@ class PapyrusPowerSyncService implements BookRepository { StreamController.broadcast(); PowerSyncDatabase? _database; + LibraryDatabase? _library; + LibrarySnapshot? _snapshot; + final _libraryController = StreamController.broadcast(); + + LibraryDatabase get _activeLibrary { + final library = _library; + if (library == null) throw StateError('Library database is not active'); + return library; + } + + @override + EditableBookRepository get scopedBooks => _activeLibrary.books; + @override + EntityRepository get shelves => _activeLibrary.shelves; + @override + EntityRepository get tags => _activeLibrary.tags; + @override + EntityRepository get notes => _activeLibrary.notes; + @override + EntityRepository get annotations => _activeLibrary.annotations; + @override + EntityRepository get bookShelves => _activeLibrary.bookShelves; + @override + EntityRepository get bookTags => _activeLibrary.bookTags; + @override + LibraryMembershipWriter get memberships => _activeLibrary; + + @override + Stream watchLibrary() => Stream.multi((listener) { + final subscription = _libraryController.stream.listen(listener.addSync, onError: listener.addErrorSync); + final current = _snapshot; + if (current != null) listener.addSync(current); + listener.onCancel = subscription.cancel; + }, isBroadcast: true); StreamSubscription? _booksSubscription; StreamSubscription? _statusSubscription; Future? _modeOperation; @@ -106,7 +149,11 @@ class PapyrusPowerSyncService implements BookRepository { throw StateError('Only guest libraries can be cleared with clearGuestLibrary'); } final database = _requireDatabase(); - await database.execute('DELETE FROM books'); + await database.writeTransaction((tx) async { + for (final table in libraryTableNames.reversed) { + await tx.execute('DELETE FROM $table'); + } + }); _booksController.add(const []); _setSyncState(const SyncState()); _setBookMetadataSyncState(const BookMetadataSyncState()); @@ -157,28 +204,19 @@ class PapyrusPowerSyncService implements BookRepository { @override Future upsert(Book book) async { - final database = _requireDatabase(); - final row = PowerSyncBookMapper.toRow(book); - final existing = await database.getOptional('SELECT id FROM books WHERE id = ?', [book.id]); - if (existing == null) { - await database.execute(PowerSyncBookMapper.insertSql(), PowerSyncBookMapper.rowParameters(row)); - } else { - await database.execute(PowerSyncBookMapper.updateSql(), PowerSyncBookMapper.updateParameters(row)); - } - await _refreshPendingWrites(); + await scopedBooks.upsert(book); } @override Future delete(String id) async { - final database = _requireDatabase(); - await database.execute('DELETE FROM books WHERE id = ?', [id]); - await _refreshPendingWrites(); + await scopedBooks.delete(id); } Future close() async { await _modeOperation; await _closeActive(clearAuthenticated: false); await _booksController.close(); + await _libraryController.close(); await _syncStateController.close(); await _bookMetadataSyncStateController.close(); } @@ -221,6 +259,8 @@ class PapyrusPowerSyncService implements BookRepository { ); await database.initialize(); _database = database; + _library = LibraryDatabase(database, _refreshPendingWrites); + await _library!.migrateLegacyBooks(); _watchBooks(database); if (mode == LibraryDatabaseMode.authenticated && connectAuthenticated) { @@ -234,11 +274,21 @@ class PapyrusPowerSyncService implements BookRepository { void _watchBooks(PowerSyncDatabase database) { unawaited(_booksSubscription?.cancel()); + final library = _activeLibrary; _booksSubscription = database - .watch('SELECT * FROM books ORDER BY added_at DESC', triggerOnTables: ['books']) - .listen((rows) { - _booksController.add(rows.map((row) => PowerSyncBookMapper.fromRow(Map.from(row))).toList()); - }); + .watch('SELECT count(*) FROM books', triggerOnTables: libraryTableNames) + .asyncMap((_) => library.snapshot()) + .listen( + (snapshot) { + if (!library.active) return; + _snapshot = snapshot; + _booksController.add(snapshot.books); + _libraryController.add(snapshot); + }, + onError: (Object error, StackTrace stack) { + if (library.active) _libraryController.addError(error, stack); + }, + ); } void _watchStatus(PowerSyncDatabase database) { @@ -349,6 +399,14 @@ class PapyrusPowerSyncService implements BookRepository { Future _closeActive({required bool clearAuthenticated}) async { _syncStateRevisions.invalidate(); + final library = _library; + if (library != null) { + library.active = false; + _library = null; + _snapshot = const LibrarySnapshot(); + _booksController.add(const []); + _libraryController.add(_snapshot!); + } await _booksSubscription?.cancel(); await _statusSubscription?.cancel(); _booksSubscription = null; diff --git a/app/lib/providers/annotations_provider.dart b/app/lib/providers/annotations_provider.dart index b9a4236..5f86111 100644 --- a/app/lib/providers/annotations_provider.dart +++ b/app/lib/providers/annotations_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/annotation.dart'; /// Sort options for annotations. @@ -134,14 +135,23 @@ class AnnotationsProvider extends ChangeNotifier { // CRUD (delegated to DataStore) // ============================================================================ - void updateAnnotationNote(String annotationId, String? note) { - final annotation = _dataStore?.getAnnotation(annotationId); + Future updateAnnotationNote( + String annotationId, + String? note, { + Annotation? previous, + EntityRepository? repository, + }) async { + final annotation = previous ?? _dataStore?.getAnnotation(annotationId); if (annotation == null || _dataStore == null) return; - _dataStore!.updateAnnotation(annotation.copyWith(note: note)); + await _dataStore!.updateAnnotation( + annotation.copyWith(note: note, clearNote: note == null), + previous: annotation, + repository: repository, + ); } - void deleteAnnotation(String annotationId) { - _dataStore?.deleteAnnotation(annotationId); + Future deleteAnnotation(String annotationId, {EntityRepository? repository}) async { + await _dataStore?.deleteAnnotation(annotationId, repository: repository); } // ============================================================================ diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 0b5a38e..5f292ea 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/data/sample_data.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/models/book.dart'; @@ -35,11 +36,9 @@ class BookDetailsProvider extends ChangeNotifier { /// Called when DataStore changes - refresh current book if it was updated. void _onDataStoreChanged() { if (_currentBookId != null && _dataStore != null) { - final updatedBook = _dataStore!.getBook(_currentBookId!); - if (updatedBook != null && updatedBook != _book) { - _book = updatedBook; - notifyListeners(); - } + _book = _dataStore!.getBook(_currentBookId!); + // Child records can change independently of the book metadata. + notifyListeners(); } } @@ -167,27 +166,24 @@ class BookDetailsProvider extends ChangeNotifier { } /// Add a new note. Persists to DataStore. - void addNote(Note note) { + Future addNote(Note note, {EntityRepository? repository}) async { if (_dataStore != null) { - _dataStore!.addNote(note); + await _dataStore!.addNote(note, repository: repository); } - notifyListeners(); } /// Update an existing note. Persists to DataStore. - void updateNote(String noteId, Note updatedNote) { + Future updateNote(String noteId, Note updatedNote, {Note? previous, EntityRepository? repository}) async { if (_dataStore != null) { - _dataStore!.updateNote(updatedNote); + await _dataStore!.updateNote(updatedNote, previous: previous, repository: repository); } - notifyListeners(); } /// Delete a note. Persists to DataStore. - void deleteNote(String noteId) { + Future deleteNote(String noteId, {EntityRepository? repository}) async { if (_dataStore != null) { - _dataStore!.deleteNote(noteId); + await _dataStore!.deleteNote(noteId, repository: repository); } - notifyListeners(); } /// Update a bookmark's note. Persists to DataStore. @@ -213,35 +209,45 @@ class BookDetailsProvider extends ChangeNotifier { } /// Add a new annotation. Persists to DataStore. - void addAnnotation(Annotation annotation) { + Future addAnnotation(Annotation annotation, {EntityRepository? repository}) async { if (_dataStore != null) { - _dataStore!.addAnnotation(annotation); + await _dataStore!.addAnnotation(annotation, repository: repository); } - notifyListeners(); } /// Update an annotation's note. Persists to DataStore. - void updateAnnotationNote(String annotationId, String? note) { - final annotation = _dataStore?.getAnnotation(annotationId); + Future updateAnnotationNote( + String annotationId, + String? note, { + Annotation? previous, + EntityRepository? repository, + }) async { + final annotation = previous ?? _dataStore?.getAnnotation(annotationId); if (annotation == null || _dataStore == null) return; - _dataStore!.updateAnnotation(annotation.copyWith(note: note)); - notifyListeners(); + await _dataStore!.updateAnnotation( + annotation.copyWith(note: note, clearNote: note == null), + previous: annotation, + repository: repository, + ); } /// Update an existing annotation. Persists to DataStore. - void updateAnnotation(String annotationId, Annotation updatedAnnotation) { + Future updateAnnotation( + String annotationId, + Annotation updatedAnnotation, { + Annotation? previous, + EntityRepository? repository, + }) async { if (_dataStore != null) { - _dataStore!.updateAnnotation(updatedAnnotation); + await _dataStore!.updateAnnotation(updatedAnnotation, previous: previous, repository: repository); } - notifyListeners(); } /// Delete an annotation. Persists to DataStore. - void deleteAnnotation(String annotationId) { + Future deleteAnnotation(String annotationId, {EntityRepository? repository}) async { if (_dataStore != null) { - _dataStore!.deleteAnnotation(annotationId); + await _dataStore!.deleteAnnotation(annotationId, repository: repository); } - notifyListeners(); } /// Toggle favorite status. Persists to DataStore. diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index 936ba21..0bd7a6e 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/services/metadata_service.dart'; @@ -9,6 +10,7 @@ enum MetadataFetchState { idle, loading, success, error } /// Provider for book edit page state management. class BookEditProvider extends ChangeNotifier { DataStore? _dataStore; + BookRepository? _repository; final MetadataService _metadataService; // Book state @@ -83,6 +85,7 @@ class BookEditProvider extends ChangeNotifier { try { final dataStore = _dataStore!; final repository = dataStore.requireBookRepository(); + _repository = repository; var book = dataStore.getBook(bookId) ?? await repository.getById(bookId); if (book == null && !dataStore.isLoaded) { await dataStore.waitUntilLoaded(); @@ -112,7 +115,7 @@ class BookEditProvider extends ChangeNotifier { try { final bookToSave = _editedBook!; - await _dataStore!.updateBookAndWait(bookToSave); + await _dataStore!.updateBookAndWait(bookToSave, previous: _originalBook, repository: _repository); _originalBook = bookToSave; _coverImageBytes = null; _isSaving = false; diff --git a/app/lib/providers/notes_provider.dart b/app/lib/providers/notes_provider.dart index 6d76c96..2732e9d 100644 --- a/app/lib/providers/notes_provider.dart +++ b/app/lib/providers/notes_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/note.dart'; /// Sort options for notes. @@ -144,18 +145,18 @@ class NotesProvider extends ChangeNotifier { // CRUD (delegated to DataStore) // ============================================================================ - void updateNote(Note note) { - _dataStore?.updateNote(note); + Future updateNote(Note note, {Note? previous, EntityRepository? repository}) async { + await _dataStore?.updateNote(note, previous: previous, repository: repository); } - void deleteNote(String noteId) { - _dataStore?.deleteNote(noteId); + Future deleteNote(String noteId, {EntityRepository? repository}) async { + await _dataStore?.deleteNote(noteId, repository: repository); } - void togglePin(String noteId) { + Future togglePin(String noteId) async { final note = _dataStore?.getNote(noteId); if (note == null || _dataStore == null) return; - _dataStore!.updateNote(note.copyWith(isPinned: !note.isPinned)); + await _dataStore!.updateNote(note.copyWith(isPinned: !note.isPinned), previous: note); } // ============================================================================ diff --git a/app/lib/providers/shelves_provider.dart b/app/lib/providers/shelves_provider.dart index 02f39b8..9f3965c 100644 --- a/app/lib/providers/shelves_provider.dart +++ b/app/lib/providers/shelves_provider.dart @@ -1,5 +1,7 @@ +import 'package:uuid/uuid.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/shelf.dart'; /// View mode for displaying shelves. @@ -247,14 +249,20 @@ class ShelvesProvider extends ChangeNotifier { } /// Creates a new shelf. - Future createShelf({required String name, String? description, String? colorHex, IconData? icon}) async { + Future createShelf({ + required String name, + String? description, + String? colorHex, + IconData? icon, + EntityRepository? repository, + }) async { if (_dataStore == null) { throw Exception('DataStore not attached'); } final now = DateTime.now(); final newShelf = Shelf( - id: 'shelf-${now.millisecondsSinceEpoch}', + id: const Uuid().v4(), name: name, description: description, colorHex: colorHex, @@ -264,13 +272,15 @@ class ShelvesProvider extends ChangeNotifier { updatedAt: now, ); - _dataStore!.addShelf(newShelf); + await _dataStore!.addShelf(newShelf, repository: repository); return newShelf; } /// Updates an existing shelf. Future updateShelf({ required String shelfId, + Shelf? previous, + EntityRepository? repository, String? name, String? description, bool clearDescription = false, @@ -281,7 +291,7 @@ class ShelvesProvider extends ChangeNotifier { throw Exception('DataStore not attached'); } - final shelf = _dataStore!.getShelf(shelfId); + final shelf = previous ?? _dataStore!.getShelf(shelfId); if (shelf == null) { throw Exception('Shelf not found'); } @@ -295,7 +305,7 @@ class ShelvesProvider extends ChangeNotifier { updatedAt: DateTime.now(), ); - _dataStore!.updateShelf(updatedShelf); + await _dataStore!.updateShelf(updatedShelf, previous: shelf, repository: repository); // Update selected shelf if it's the one being edited if (_selectedShelf?.id == shelfId) { @@ -304,7 +314,7 @@ class ShelvesProvider extends ChangeNotifier { } /// Deletes a shelf by ID. - Future deleteShelf(String shelfId) async { + Future deleteShelf(String shelfId, {EntityRepository? repository}) async { if (_dataStore == null) { throw Exception('DataStore not attached'); } @@ -314,7 +324,7 @@ class ShelvesProvider extends ChangeNotifier { throw Exception('Shelf not found'); } - _dataStore!.deleteShelf(shelfId); + await _dataStore!.deleteShelf(shelfId, repository: repository); // Clear selected shelf if it was deleted if (_selectedShelf?.id == shelfId) { @@ -328,12 +338,12 @@ class ShelvesProvider extends ChangeNotifier { throw Exception('DataStore not attached'); } - _dataStore!.addBookToShelf(bookId, shelfId); + await _dataStore!.addBookToShelf(bookId, shelfId); // Update the shelf's updatedAt timestamp final shelf = _dataStore!.getShelf(shelfId); if (shelf != null) { - _dataStore!.updateShelf(shelf.copyWith(updatedAt: DateTime.now())); + await _dataStore!.updateShelf(shelf.copyWith(updatedAt: DateTime.now()), previous: shelf); } } @@ -343,17 +353,17 @@ class ShelvesProvider extends ChangeNotifier { throw Exception('DataStore not attached'); } - _dataStore!.removeBookFromShelf(bookId, shelfId); + await _dataStore!.removeBookFromShelf(bookId, shelfId); // Update the shelf's updatedAt timestamp final shelf = _dataStore!.getShelf(shelfId); if (shelf != null) { - _dataStore!.updateShelf(shelf.copyWith(updatedAt: DateTime.now())); + await _dataStore!.updateShelf(shelf.copyWith(updatedAt: DateTime.now()), previous: shelf); } } /// Reorders shelves (drag and drop). - void reorderShelves(int oldIndex, int newIndex) { + Future reorderShelves(int oldIndex, int newIndex) async { if (_dataStore == null) return; final shelfList = List.from(_dataStore!.shelves); @@ -367,7 +377,7 @@ class ShelvesProvider extends ChangeNotifier { // Update sort orders in DataStore for (var i = 0; i < shelfList.length; i++) { - _dataStore!.updateShelf(shelfList[i].copyWith(sortOrder: i)); + await _dataStore!.updateShelf(shelfList[i].copyWith(sortOrder: i), previous: shelfList[i]); } } diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index 3c43d76..a502897 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -113,27 +113,18 @@ Future _downloadBookFile(BuildContext context, Book book) async { void _showManageTopicsSheet(BuildContext context, Book book) { final dataStore = context.read(); final currentTagIds = dataStore.getTagIdsForBook(book.id).toSet(); + final repository = dataStore.libraryRepository?.memberships; + final bookId = book.id; ManageTopicsSheet.show( context, book: book, - onSave: (newTagIds) { - final newTagSet = newTagIds.toSet(); - - // Remove book from topics it was removed from - for (final tagId in currentTagIds) { - if (!newTagSet.contains(tagId)) { - dataStore.removeTagFromBook(book.id, tagId); - } - } - - // Add book to new topics - for (final tagId in newTagIds) { - if (!currentTagIds.contains(tagId)) { - dataStore.addTagToBook(book.id, tagId); - } - } - }, + onSave: (newTagIds) => dataStore.updateBookMemberships( + bookIds: {bookId}, + tagIds: newTagIds, + previousTagIds: currentTagIds, + repository: repository, + ), ); } @@ -141,26 +132,17 @@ void _showManageTopicsSheet(BuildContext context, Book book) { void _showMoveToShelfSheet(BuildContext context, Book book) { final dataStore = context.read(); final currentShelfIds = dataStore.getShelfIdsForBook(book.id).toSet(); + final repository = dataStore.libraryRepository?.memberships; + final bookId = book.id; MoveToShelfSheet.show( context, book: book, - onSave: (newShelfIds) { - final newShelfSet = newShelfIds.toSet(); - - // Remove book from shelves it was removed from - for (final shelfId in currentShelfIds) { - if (!newShelfSet.contains(shelfId)) { - dataStore.removeBookFromShelf(book.id, shelfId); - } - } - - // Add book to new shelves - for (final shelfId in newShelfIds) { - if (!currentShelfIds.contains(shelfId)) { - dataStore.addBookToShelf(book.id, shelfId); - } - } - }, + onSave: (newShelfIds) => dataStore.updateBookMemberships( + bookIds: {bookId}, + shelfIds: newShelfIds, + previousShelfIds: currentShelfIds, + repository: repository, + ), ); } diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index dbb923d..58b71a1 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/providers/library_provider.dart'; @@ -18,22 +19,20 @@ import 'package:provider/provider.dart'; // ============================================================================= /// Add all selected books to the given shelves. -void bulkAddToShelves(DataStore dataStore, Set bookIds, List shelfIds) { - for (final bookId in bookIds) { - for (final shelfId in shelfIds) { - dataStore.addBookToShelf(bookId, shelfId); - } - } -} +Future bulkAddToShelves( + DataStore dataStore, + Set bookIds, + List shelfIds, { + LibraryMembershipWriter? repository, +}) => dataStore.updateBookMemberships(bookIds: bookIds, shelfIds: shelfIds, additive: true, repository: repository); /// Set topics for all selected books (additive — does not remove existing). -void bulkAddTopics(DataStore dataStore, Set bookIds, List tagIds) { - for (final bookId in bookIds) { - for (final tagId in tagIds) { - dataStore.addTagToBook(bookId, tagId); - } - } -} +Future bulkAddTopics( + DataStore dataStore, + Set bookIds, + List tagIds, { + LibraryMembershipWriter? repository, +}) => dataStore.updateBookMemberships(bookIds: bookIds, tagIds: tagIds, additive: true, repository: repository); /// Change reading status for all selected books. void bulkChangeStatus(DataStore dataStore, Set bookIds, LibraryReadingStatus status) { @@ -82,12 +81,13 @@ void bulkDelete(DataStore dataStore, Set bookIds) { void handleBulkAddToShelf(BuildContext context, LibraryProvider libraryProvider) { final dataStore = context.read(); final selectedIds = libraryProvider.selectedBookIds.toList(); + final repository = dataStore.libraryRepository?.memberships; MoveToShelfSheet.showBulk( context, bookIds: selectedIds, - onSave: (shelfIds) { - bulkAddToShelves(dataStore, libraryProvider.selectedBookIds, shelfIds); + onSave: (shelfIds) async { + await bulkAddToShelves(dataStore, selectedIds.toSet(), shelfIds, repository: repository); libraryProvider.exitSelectionMode(); }, ); @@ -97,12 +97,13 @@ void handleBulkAddToShelf(BuildContext context, LibraryProvider libraryProvider) void handleBulkManageTopics(BuildContext context, LibraryProvider libraryProvider) { final dataStore = context.read(); final selectedIds = libraryProvider.selectedBookIds.toList(); + final repository = dataStore.libraryRepository?.memberships; ManageTopicsSheet.showBulk( context, bookIds: selectedIds, - onSave: (tagIds) { - bulkAddTopics(dataStore, libraryProvider.selectedBookIds, tagIds); + onSave: (tagIds) async { + await bulkAddTopics(dataStore, selectedIds.toSet(), tagIds, repository: repository); libraryProvider.exitSelectionMode(); }, ); diff --git a/app/lib/widgets/annotations/annotation_action_sheet.dart b/app/lib/widgets/annotations/annotation_action_sheet.dart index c5fc345..c833d71 100644 --- a/app/lib/widgets/annotations/annotation_action_sheet.dart +++ b/app/lib/widgets/annotations/annotation_action_sheet.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -11,18 +14,23 @@ import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; /// Bottom sheet for editing an annotation's attached note. class AnnotationNoteSheet extends StatefulWidget { final Annotation annotation; + final FutureOr Function(String)? onSave; - const AnnotationNoteSheet({super.key, required this.annotation}); + const AnnotationNoteSheet({super.key, required this.annotation, this.onSave}); /// Show the note editing sheet. Returns the new note text, or null if cancelled. - static Future show(BuildContext context, {required Annotation annotation}) { + static Future show( + BuildContext context, { + required Annotation annotation, + FutureOr Function(String)? onSave, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), ), - builder: (context) => AnnotationNoteSheet(annotation: annotation), + builder: (context) => AnnotationNoteSheet(annotation: annotation, onSave: onSave), ); } @@ -30,7 +38,7 @@ class AnnotationNoteSheet extends StatefulWidget { State createState() => _AnnotationNoteSheetState(); } -class _AnnotationNoteSheetState extends State { +class _AnnotationNoteSheetState extends State with PersistentSave { late TextEditingController _controller; @override @@ -63,9 +71,12 @@ class _AnnotationNoteSheetState extends State { BottomSheetHeader( title: 'Edit note', onCancel: () => Navigator.pop(context), - onSave: () { + canSave: !isSaving, + canCancel: !isSaving, + onSave: () async { final text = _controller.text.trim(); - Navigator.pop(context, text.isEmpty ? '' : text); + final saved = await persist(() => widget.onSave?.call(text)); + if (saved && context.mounted) Navigator.pop(context, text); }, ), const SizedBox(height: Spacing.md), diff --git a/app/lib/widgets/book/book_details.dart b/app/lib/widgets/book/book_details.dart index 16e97c5..64c03c3 100644 --- a/app/lib/widgets/book/book_details.dart +++ b/app/lib/widgets/book/book_details.dart @@ -220,50 +220,36 @@ class _BookDetailsState extends State { void _showMoveToShelfSheet(BuildContext context) { final dataStore = context.read(); final currentShelfIds = dataStore.getShelfIdsForBook(widget.book.id).toSet(); + final repository = dataStore.libraryRepository?.memberships; + final bookId = widget.book.id; MoveToShelfSheet.show( context, book: widget.book, - onSave: (newShelfIds) { - final newShelfSet = newShelfIds.toSet(); - - for (final shelfId in currentShelfIds) { - if (!newShelfSet.contains(shelfId)) { - dataStore.removeBookFromShelf(widget.book.id, shelfId); - } - } - - for (final shelfId in newShelfIds) { - if (!currentShelfIds.contains(shelfId)) { - dataStore.addBookToShelf(widget.book.id, shelfId); - } - } - }, + onSave: (newShelfIds) => dataStore.updateBookMemberships( + bookIds: {bookId}, + shelfIds: newShelfIds, + previousShelfIds: currentShelfIds, + repository: repository, + ), ); } void _showManageTopicsSheet(BuildContext context) { final dataStore = context.read(); final currentTagIds = dataStore.getTagIdsForBook(widget.book.id).toSet(); + final repository = dataStore.libraryRepository?.memberships; + final bookId = widget.book.id; ManageTopicsSheet.show( context, book: widget.book, - onSave: (newTagIds) { - final newTagSet = newTagIds.toSet(); - - for (final tagId in currentTagIds) { - if (!newTagSet.contains(tagId)) { - dataStore.removeTagFromBook(widget.book.id, tagId); - } - } - - for (final tagId in newTagIds) { - if (!currentTagIds.contains(tagId)) { - dataStore.addTagToBook(widget.book.id, tagId); - } - } - }, + onSave: (newTagIds) => dataStore.updateBookMemberships( + bookIds: {bookId}, + tagIds: newTagIds, + previousTagIds: currentTagIds, + repository: repository, + ), ); } } diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index 151a010..bd3b1f7 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:papyrus/models/annotation.dart'; @@ -9,17 +13,23 @@ import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; class AnnotationDialog extends StatefulWidget { final String bookId; final Annotation? existingAnnotation; + final FutureOr Function(Annotation)? onSave; - const AnnotationDialog({super.key, required this.bookId, this.existingAnnotation}); + const AnnotationDialog({super.key, required this.bookId, this.existingAnnotation, 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}) { + static Future show( + BuildContext context, { + required String bookId, + Annotation? existingAnnotation, + FutureOr Function(Annotation)? onSave, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, useRootNavigator: true, useSafeArea: true, - builder: (context) => AnnotationDialog(bookId: bookId, existingAnnotation: existingAnnotation), + builder: (context) => AnnotationDialog(bookId: bookId, existingAnnotation: existingAnnotation, onSave: onSave), ); } @@ -27,7 +37,7 @@ class AnnotationDialog extends StatefulWidget { State createState() => _AnnotationDialogState(); } -class _AnnotationDialogState extends State { +class _AnnotationDialogState extends State with PersistentSave { final _formKey = GlobalKey(); late final TextEditingController _textController; late final TextEditingController _pageController; @@ -56,7 +66,8 @@ class _AnnotationDialogState extends State { super.dispose(); } - void _save() { + Future _save() async { + if (isSaving) return; if (!(_formKey.currentState?.validate() ?? false)) return; final page = int.parse(_pageController.text); @@ -64,7 +75,7 @@ class _AnnotationDialogState extends State { final note = _noteController.text.trim(); final annotation = Annotation( - id: widget.existingAnnotation?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), + id: widget.existingAnnotation?.id ?? const Uuid().v4(), bookId: widget.bookId, selectedText: _textController.text.trim(), color: _selectedColor, @@ -73,7 +84,8 @@ class _AnnotationDialogState extends State { createdAt: widget.existingAnnotation?.createdAt ?? DateTime.now(), updatedAt: _isEditing ? DateTime.now() : null, ); - Navigator.of(context).pop(annotation); + final saved = await persist(() => widget.onSave?.call(annotation)); + if (saved && mounted) Navigator.of(context).pop(annotation); } @override @@ -103,6 +115,8 @@ class _AnnotationDialogState extends State { title: _isEditing ? 'Edit annotation' : 'New annotation', onCancel: () => Navigator.of(context).pop(), onSave: _save, + canSave: !isSaving, + canCancel: !isSaving, ), ], ), diff --git a/app/lib/widgets/book_details/note_dialog.dart b/app/lib/widgets/book_details/note_dialog.dart index daf218d..63dd5d0 100644 --- a/app/lib/widgets/book_details/note_dialog.dart +++ b/app/lib/widgets/book_details/note_dialog.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -8,19 +12,25 @@ import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; class NoteDialog extends StatelessWidget { final String bookId; final Note? existingNote; + final FutureOr Function(Note)? onSave; - const NoteDialog({super.key, required this.bookId, this.existingNote}); + const NoteDialog({super.key, required this.bookId, this.existingNote, this.onSave}); bool get isEditing => existingNote != null; /// Shows the dialog and returns the created/updated note, or null if cancelled. - static Future show(BuildContext context, {required String bookId, Note? existingNote}) async { + static Future show( + BuildContext context, { + required String bookId, + Note? existingNote, + FutureOr Function(Note)? onSave, + }) async { return showModalBottomSheet( context: context, isScrollControlled: true, useRootNavigator: true, useSafeArea: true, - builder: (context) => _BottomSheetNote(bookId: bookId, existingNote: existingNote), + builder: (context) => _BottomSheetNote(bookId: bookId, existingNote: existingNote, onSave: onSave), ); } @@ -34,14 +44,15 @@ class NoteDialog extends StatelessWidget { class _BottomSheetNote extends StatefulWidget { final String bookId; final Note? existingNote; + final FutureOr Function(Note)? onSave; - const _BottomSheetNote({required this.bookId, this.existingNote}); + const _BottomSheetNote({required this.bookId, this.existingNote, this.onSave}); @override State<_BottomSheetNote> createState() => _BottomSheetNoteState(); } -class _BottomSheetNoteState extends State<_BottomSheetNote> { +class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave<_BottomSheetNote> { final _formKey = GlobalKey(); late final TextEditingController _titleController; late final TextEditingController _contentController; @@ -92,19 +103,22 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> { }); } - void _save() { + Future _save() async { + if (isSaving) return; if (_formKey.currentState?.validate() ?? false) { final note = Note( - id: widget.existingNote?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), + id: widget.existingNote?.id ?? const Uuid().v4(), 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(), updatedAt: isEditing ? DateTime.now() : null, ); - Navigator.of(context).pop(note); + final saved = await persist(() => widget.onSave?.call(note)); + if (saved && mounted) Navigator.of(context).pop(note); } } @@ -136,6 +150,8 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> { title: isEditing ? 'Edit note' : 'New note', onCancel: () => Navigator.of(context).pop(), onSave: _save, + canSave: !isSaving, + canCancel: !isSaving, ), ], ), diff --git a/app/lib/widgets/shared/persistent_save.dart b/app/lib/widgets/shared/persistent_save.dart new file mode 100644 index 0000000..f12ccad --- /dev/null +++ b/app/lib/widgets/shared/persistent_save.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Keeps editors open until their local write succeeds. +mixin PersistentSave on State { + bool isSaving = false; + + Future persist(FutureOr Function() save) async { + if (isSaving) return false; + setState(() => isSaving = true); + try { + await save(); + return mounted; + } catch (_) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not save changes. Please try again.'))); + } + return false; + } finally { + if (mounted) setState(() => isSaving = false); + } + } +} diff --git a/app/lib/widgets/shelves/add_shelf_sheet.dart b/app/lib/widgets/shelves/add_shelf_sheet.dart index 5e92866..e521fe7 100644 --- a/app/lib/widgets/shelves/add_shelf_sheet.dart +++ b/app/lib/widgets/shelves/add_shelf_sheet.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/color_utils.dart'; @@ -10,7 +13,7 @@ class AddShelfSheet extends StatefulWidget { final ShelfData? shelf; /// Called when the shelf is saved. - final void Function(String name, String? description, String? colorHex, IconData? icon)? onSave; + final FutureOr Function(String name, String? description, String? colorHex, IconData? icon)? onSave; const AddShelfSheet({super.key, this.shelf, this.onSave}); @@ -18,7 +21,7 @@ class AddShelfSheet extends StatefulWidget { static Future show( BuildContext context, { ShelfData? shelf, - void Function(String name, String? description, String? colorHex, IconData? icon)? onSave, + FutureOr Function(String name, String? description, String? colorHex, IconData? icon)? onSave, }) { return showModalBottomSheet( context: context, @@ -33,7 +36,7 @@ class AddShelfSheet extends StatefulWidget { State createState() => _AddShelfSheetState(); } -class _AddShelfSheetState extends State { +class _AddShelfSheetState extends State with PersistentSave { late TextEditingController _nameController; late TextEditingController _descriptionController; String? _selectedColorHex; @@ -146,7 +149,7 @@ class _AddShelfSheetState extends State { ); } - bool get _canSave => _nameController.text.trim().isNotEmpty; + bool get _canSave => !isSaving && _nameController.text.trim().isNotEmpty; Widget _buildColorPicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -273,16 +276,18 @@ class _AddShelfSheetState extends State { ); } - void _onSave() { + Future _onSave() async { final name = _nameController.text.trim(); if (name.isEmpty) return; - widget.onSave?.call( - name, - _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), - _selectedColorHex, - _selectedIcon, + final saved = await persist( + () => widget.onSave?.call( + name, + _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), + _selectedColorHex, + _selectedIcon, + ), ); - Navigator.of(context).pop(); + if (saved && mounted) Navigator.of(context).pop(); } } diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index 51d4750..55fdc89 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -1,5 +1,10 @@ +import 'package:uuid/uuid.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -20,14 +25,18 @@ class MoveToShelfSheet extends StatefulWidget { final List? bulkBookIds; /// Called when shelf assignments change. - final void Function(List shelfIds)? onSave; + final FutureOr Function(List shelfIds)? onSave; const MoveToShelfSheet({super.key, this.book, this.bulkBookIds, this.onSave}); bool get isBulkMode => bulkBookIds != null && bulkBookIds!.isNotEmpty; /// Shows the move to shelf sheet for a single book. - static Future show(BuildContext context, {required Book book, void Function(List shelfIds)? onSave}) { + static Future show( + BuildContext context, { + required Book book, + FutureOr Function(List shelfIds)? onSave, + }) { return showModalBottomSheet( context: context, useRootNavigator: true, @@ -41,7 +50,7 @@ class MoveToShelfSheet extends StatefulWidget { static Future showBulk( BuildContext context, { required List bookIds, - void Function(List shelfIds)? onSave, + FutureOr Function(List shelfIds)? onSave, }) { return showModalBottomSheet( context: context, @@ -55,14 +64,16 @@ class MoveToShelfSheet extends StatefulWidget { State createState() => _MoveToShelfSheetState(); } -class _MoveToShelfSheetState extends State { +class _MoveToShelfSheetState extends State with PersistentSave { late Set _selectedShelfIds; final _searchController = TextEditingController(); String _searchQuery = ''; + EntityRepository? _repository; @override void initState() { super.initState(); + _repository = context.read().libraryRepository?.shelves; if (widget.isBulkMode) { _selectedShelfIds = {}; } else { @@ -193,7 +204,7 @@ class _MoveToShelfSheetState extends State { children: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), const SizedBox(width: Spacing.md), - FilledButton(onPressed: _onSave, child: const Text('Save')), + FilledButton(onPressed: isSaving ? null : _onSave, child: const Text('Save')), ], ), ), @@ -292,13 +303,14 @@ class _MoveToShelfSheetState extends State { void _showCreateShelfSheet() { final dataStore = context.read(); + final repository = _repository; AddShelfSheet.show( context, - onSave: (name, description, colorHex, icon) { + onSave: (name, description, colorHex, icon) async { final now = DateTime.now(); final newShelf = Shelf( - id: 'shelf-${now.millisecondsSinceEpoch}', + id: const Uuid().v4(), name: name, description: description, colorHex: colorHex, @@ -307,7 +319,8 @@ class _MoveToShelfSheetState extends State { createdAt: now, updatedAt: now, ); - dataStore.addShelf(newShelf); + await dataStore.addShelf(newShelf, repository: repository); + if (!mounted) return; // Auto-select the newly created shelf setState(() { @@ -317,8 +330,8 @@ class _MoveToShelfSheetState extends State { ); } - void _onSave() { - widget.onSave?.call(_selectedShelfIds.toList()); - Navigator.pop(context); + Future _onSave() async { + final saved = await persist(() => widget.onSave?.call(_selectedShelfIds.toList())); + if (saved && mounted) Navigator.pop(context); } } diff --git a/app/lib/widgets/topics/add_topic_sheet.dart b/app/lib/widgets/topics/add_topic_sheet.dart index 5523c3c..6b051be 100644 --- a/app/lib/widgets/topics/add_topic_sheet.dart +++ b/app/lib/widgets/topics/add_topic_sheet.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:papyrus/models/tag.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/color_utils.dart'; @@ -10,7 +13,7 @@ class AddTopicSheet extends StatefulWidget { final Tag? topic; /// Called when the topic is saved. - final void Function(String name, String? description, String colorHex)? onSave; + final FutureOr Function(String name, String? description, String colorHex)? onSave; const AddTopicSheet({super.key, this.topic, this.onSave}); @@ -18,7 +21,7 @@ class AddTopicSheet extends StatefulWidget { static Future show( BuildContext context, { Tag? topic, - void Function(String name, String? description, String colorHex)? onSave, + FutureOr Function(String name, String? description, String colorHex)? onSave, }) { return showModalBottomSheet( context: context, @@ -32,7 +35,7 @@ class AddTopicSheet extends StatefulWidget { State createState() => _AddTopicSheetState(); } -class _AddTopicSheetState extends State { +class _AddTopicSheetState extends State with PersistentSave { late TextEditingController _nameController; late TextEditingController _descriptionController; late String _selectedColorHex; @@ -138,7 +141,7 @@ class _AddTopicSheetState extends State { ); } - bool get _canSave => _nameController.text.trim().isNotEmpty; + bool get _canSave => !isSaving && _nameController.text.trim().isNotEmpty; Widget _buildColorPicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -231,15 +234,17 @@ class _AddTopicSheetState extends State { ); } - void _onSave() { + Future _onSave() async { final name = _nameController.text.trim(); if (name.isEmpty) return; - widget.onSave?.call( - name, - _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), - _selectedColorHex, + final saved = await persist( + () => widget.onSave?.call( + name, + _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), + _selectedColorHex, + ), ); - Navigator.of(context).pop(); + if (saved && mounted) Navigator.of(context).pop(); } } diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index db5b203..29eb0a9 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -1,5 +1,10 @@ +import 'package:uuid/uuid.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/tag.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -20,14 +25,18 @@ class ManageTopicsSheet extends StatefulWidget { final List? bulkBookIds; /// Called when topic assignments change. - final void Function(List tagIds)? onSave; + final FutureOr Function(List tagIds)? onSave; const ManageTopicsSheet({super.key, this.book, this.bulkBookIds, this.onSave}); bool get isBulkMode => bulkBookIds != null && bulkBookIds!.isNotEmpty; /// Shows the manage topics sheet for a single book. - static Future show(BuildContext context, {required Book book, void Function(List tagIds)? onSave}) { + static Future show( + BuildContext context, { + required Book book, + FutureOr Function(List tagIds)? onSave, + }) { return showModalBottomSheet( context: context, useRootNavigator: true, @@ -41,7 +50,7 @@ class ManageTopicsSheet extends StatefulWidget { static Future showBulk( BuildContext context, { required List bookIds, - void Function(List tagIds)? onSave, + FutureOr Function(List tagIds)? onSave, }) { return showModalBottomSheet( context: context, @@ -55,14 +64,16 @@ class ManageTopicsSheet extends StatefulWidget { State createState() => _ManageTopicsSheetState(); } -class _ManageTopicsSheetState extends State { +class _ManageTopicsSheetState extends State with PersistentSave { late Set _selectedTagIds; final _searchController = TextEditingController(); String _searchQuery = ''; + EntityRepository? _repository; @override void initState() { super.initState(); + _repository = context.read().libraryRepository?.tags; if (widget.isBulkMode) { _selectedTagIds = {}; } else { @@ -186,7 +197,7 @@ class _ManageTopicsSheetState extends State { children: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), const SizedBox(width: Spacing.md), - FilledButton(onPressed: _onSave, child: const Text('Save')), + FilledButton(onPressed: isSaving ? null : _onSave, child: const Text('Save')), ], ), ), @@ -293,19 +304,21 @@ class _ManageTopicsSheetState extends State { void _showCreateTopicSheet() { final dataStore = context.read(); + final repository = _repository; AddTopicSheet.show( context, - onSave: (name, description, colorHex) { + onSave: (name, description, colorHex) async { final now = DateTime.now(); final newTag = Tag( - id: 'tag-${now.millisecondsSinceEpoch}', + id: const Uuid().v4(), name: name, colorHex: colorHex, description: description, createdAt: now, ); - dataStore.addTag(newTag); + await dataStore.addTag(newTag, repository: repository); + if (!mounted) return; // Auto-select the newly created topic setState(() { @@ -315,8 +328,8 @@ class _ManageTopicsSheetState extends State { ); } - void _onSave() { - widget.onSave?.call(_selectedTagIds.toList()); - Navigator.pop(context); + Future _onSave() async { + final saved = await persist(() => widget.onSave?.call(_selectedTagIds.toList())); + if (saved && mounted) Navigator.pop(context); } } diff --git a/app/lib/widgets/topics/topic_detail_sheet.dart b/app/lib/widgets/topics/topic_detail_sheet.dart index 1106964..3d5850f 100644 --- a/app/lib/widgets/topics/topic_detail_sheet.dart +++ b/app/lib/widgets/topics/topic_detail_sheet.dart @@ -112,18 +112,24 @@ class TopicDetailSheet extends StatelessWidget { void _editTag(BuildContext context) { final dataStore = context.read(); + final repository = dataStore.libraryRepository?.tags; AddTopicSheet.show( context, topic: tag, - onSave: (name, description, colorHex) { - dataStore.updateTag(tag.copyWith(name: name, description: description, colorHex: colorHex)); + onSave: (name, description, colorHex) async { + await dataStore.updateTag( + tag.copyWith(name: name, description: description, clearDescription: description == null, colorHex: colorHex), + previous: tag, + repository: repository, + ); }, ); } void _confirmDeleteTag(BuildContext context, int bookCount) { final dataStore = context.read(); + final repository = dataStore.libraryRepository?.tags; final colorScheme = Theme.of(context).colorScheme; showDialog( @@ -139,9 +145,17 @@ class TopicDetailSheet extends StatelessWidget { actions: [ TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - dataStore.deleteTag(tag.id); + onPressed: () async { + try { + await dataStore.deleteTag(tag.id, repository: repository); + if (dialogContext.mounted) Navigator.pop(dialogContext); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not delete topic. Please try again.'))); + } + } }, style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: const Text('Delete'), diff --git a/app/pubspec.lock b/app/pubspec.lock index bbb1734..b702da5 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -902,7 +902,7 @@ packages: source: hosted version: "0.9.1" sqlite_async: - dependency: transitive + dependency: "direct main" description: name: sqlite_async sha256: "17176f00a10e5b8ba6e0205e42de1c15a94ff8762745fed19750b44b6bbd5649" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ddba7d2..7c902f3 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: crypto: ^3.0.6 path_provider: ^2.1.5 powersync: ^2.3.0 + sqlite_async: ^0.14.3 web: ^1.1.1 mobile_scanner: ^7.0.1 shared_preferences: ^2.5.4 diff --git a/app/test/models/library_nullable_fields_test.dart b/app/test/models/library_nullable_fields_test.dart new file mode 100644 index 0000000..5b4f598 --- /dev/null +++ b/app/test/models/library_nullable_fields_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/tag.dart'; + +void main() { + final now = DateTime.utc(2026); + + test('nullable book metadata can be explicitly cleared', () { + final original = Book( + id: 'book', + title: 'Book', + author: 'Author', + addedAt: now, + fileMediaId: 'file', + coverMediaId: 'cover', + fileFormat: BookFormat.epub, + fileSize: 42, + fileHash: 'hash', + currentPage: 3, + currentCfi: 'cfi', + customMetadata: const {'key': 'value'}, + seriesId: 'series', + startedAt: now, + completedAt: now, + lastReadAt: now, + ); + final cleared = original + .copyWith( + clearFileMediaId: true, + clearCoverMediaId: true, + clearFileFormat: true, + clearFileSize: true, + clearFileHash: true, + clearCurrentPage: true, + clearCurrentCfi: true, + clearCustomMetadata: true, + clearSeriesId: true, + clearStartedAt: true, + clearCompletedAt: true, + clearLastReadAt: true, + ) + .toJson(); + for (final field in [ + 'file_media_id', + 'cover_media_id', + 'file_format', + 'file_size', + 'file_hash', + 'current_page', + 'current_cfi', + 'custom_metadata', + 'series_id', + 'started_at', + 'completed_at', + 'last_read_at', + ]) { + expect(cleared[field], isNull, reason: field); + expect(original.copyWith().toJson()[field], isNotNull, reason: field); + } + }); + + test('nullable library fields preserve by default and clear explicitly', () { + final shelf = Shelf( + id: 'shelf', + name: 'Shelf', + description: 'Description', + colorHex: '#123456', + icon: Icons.book, + parentShelfId: 'parent', + smartQuery: 'query', + createdAt: now, + updatedAt: now, + ); + final cleared = shelf.copyWith( + clearDescription: true, + clearColorHex: true, + clearIcon: true, + clearParentShelfId: true, + clearSmartQuery: true, + ); + expect(cleared.description, isNull); + expect(cleared.colorHex, isNull); + expect(cleared.icon, isNull); + expect(cleared.parentShelfId, isNull); + expect(cleared.smartQuery, isNull); + expect(shelf.copyWith().parentShelfId, 'parent'); + + final tag = Tag(id: 'tag', name: 'Tag', description: 'Description', colorHex: '#123456', createdAt: now); + expect(tag.copyWith(clearDescription: true).description, isNull); + expect(tag.copyWith().description, 'Description'); + final note = Note( + id: 'note', + bookId: 'book', + title: 'Note', + content: 'Content', + location: const BookLocation(pageNumber: 3), + createdAt: now, + ); + expect(note.copyWith(clearLocation: true).location, isNull); + expect(note.copyWith().location?.pageNumber, 3); + final annotation = Annotation( + id: 'annotation', + bookId: 'book', + selectedText: 'Quote', + note: 'Note', + location: const BookLocation(pageNumber: 3), + createdAt: now, + ); + expect(annotation.copyWith(clearNote: true).note, isNull); + expect(annotation.copyWith().note, 'Note'); + }); +} diff --git a/app/test/models/shelf_icon_test.dart b/app/test/models/shelf_icon_test.dart new file mode 100644 index 0000000..0e59b36 --- /dev/null +++ b/app/test/models/shelf_icon_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; + +void main() { + final now = DateTime.utc(2026); + final base = Shelf(id: 'shelf', name: 'Shelf', createdAt: now, updatedAt: now); + const descriptorKeys = ['icon_code_point', 'icon_font_family', 'icon_font_package', 'icon_match_text_direction']; + + Map descriptor(Map row) => {for (final key in descriptorKeys) key: row[key]}; + + test('all available constant icons round trip through shelf rows', () { + for (final icon in Shelf.availableIcons) { + final original = base.copyWith(icon: icon); + final row = shelfRowMapper.toRow(original); + final restored = shelfRowMapper.fromRow(row); + expect(restored.icon, icon); + expect(restored.displayIcon, icon); + expect(shelfRowMapper.toRow(restored), row); + } + }); + + test('unknown descriptor survives decode, unrelated edits, and encode', () { + final row = { + ...shelfRowMapper.toRow(base), + 'icon_code_point': 0xf1234, + 'icon_font_family': 'OtherFont', + 'icon_font_package': 'other_icons', + 'icon_match_text_direction': 1, + }; + final restored = shelfRowMapper.fromRow(row); + expect(restored.displayIcon, Icons.folder_outlined); + final edited = restored.copyWith(name: 'Edited', description: 'Description', icon: restored.icon); + expect(descriptor(shelfRowMapper.toRow(edited)), descriptor(row)); + final decodedAgain = Shelf.fromJson(edited.toJson()); + expect(descriptor(shelfRowMapper.toRow(decodedAgain)), descriptor(row)); + }); + + test('font identity and text direction must match before using a known icon', () { + final known = shelfRowMapper.toRow(base.copyWith(icon: Icons.menu_book)); + for (final overrides in [ + {'icon_font_family': 'AnotherFamily'}, + {'icon_font_package': 'another_package'}, + {'icon_match_text_direction': Icons.menu_book.matchTextDirection ? 0 : 1}, + {'icon_font_family': null}, + ]) { + final row = {...known, ...overrides}; + final restored = shelfRowMapper.fromRow(row); + expect(restored.displayIcon, Icons.folder_outlined); + expect(descriptor(shelfRowMapper.toRow(restored.copyWith(name: 'Edited'))), descriptor(row)); + } + }); + + test('choosing another icon replaces an unsupported stored descriptor', () { + final unknown = shelfRowMapper.fromRow({ + ...shelfRowMapper.toRow(base), + 'icon_code_point': 0xf1234, + 'icon_font_family': 'OtherFont', + 'icon_font_package': 'other_icons', + 'icon_match_text_direction': 1, + }); + final updated = unknown.copyWith(icon: Icons.favorite_outline); + final expected = base.copyWith(icon: Icons.favorite_outline); + expect(descriptor(shelfRowMapper.toRow(updated)), descriptor(shelfRowMapper.toRow(expected))); + }); + + test('clearing an icon removes all retained identity fields', () { + final unknown = shelfRowMapper.fromRow({ + ...shelfRowMapper.toRow(base), + 'icon_code_point': 0xf1234, + 'icon_font_family': 'OtherFont', + 'icon_font_package': 'other_icons', + 'icon_match_text_direction': 1, + }); + final cleared = unknown.copyWith(clearIcon: true); + expect(cleared.icon, isNull); + expect(cleared.iconDescriptor, isNull); + expect(descriptor(shelfRowMapper.toRow(cleared)), descriptor(shelfRowMapper.toRow(base))); + }); +} diff --git a/app/test/powersync/library_live_sync_test.dart b/app/test/powersync/library_live_sync_test.dart new file mode 100644 index 0000000..447b208 --- /dev/null +++ b/app/test/powersync/library_live_sync_test.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:uuid/uuid.dart'; + +class _MemoryRefreshStorage implements RefreshTokenStorage { + String? token; + @override + Future read() async => token; + @override + Future write(String refreshToken) async => token = refreshToken; + @override + Future delete() async => token = null; +} + +Future _eventually(Future Function() condition, String description) async { + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (DateTime.now().isBefore(deadline)) { + if (await condition()) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('Timed out: $description'); +} + +void main() { + test( + 'live devices converge after offline restart and isolate another account', + () async { + final directory = await Directory.systemTemp.createTemp('papyrus-live-library-'); + final client = http.Client(); + final config = PapyrusApiConfig(serverBaseUri: Uri.parse('http://localhost:8080')); + AuthRepository auth() => AuthRepository( + apiClient: AuthApiClient(config: config, httpClient: client), + tokenStore: TokenStore(_MemoryRefreshStorage()), + ); + final firstAuth = auth(); + final secondAuth = auth(); + final otherAuth = auth(); + final suffix = const Uuid().v4(); + final email = 'sync-check-$suffix@example.com'; + final password = 'SyncCheck-$suffix'; + final owner = await firstAuth.register( + email: email, + password: password, + displayName: 'Sync validation', + clientType: 'desktop', + ); + await secondAuth.login(email: email, password: password, clientType: 'desktop'); + final other = await otherAuth.register( + email: 'other-$email', + password: password, + displayName: 'Isolation validation', + clientType: 'desktop', + ); + + PapyrusPowerSyncService device(String name, AuthRepository repository, {bool connect = true}) { + late PapyrusPowerSyncService result; + result = PapyrusPowerSyncService( + connectAuthenticated: connect, + connectorFactory: () => PapyrusPowerSyncConnector( + authRepository: repository, + config: config, + onUploadComplete: () => result.refreshBookMetadataSyncState(), + ), + pathResolver: (_, _, _) async => '${directory.path}/$name.db', + ); + return result; + } + + final first = device('one', firstAuth); + var second = device('two', secondAuth); + final outsider = device('other', otherAuth); + final bookId = const Uuid().v4(); + final shelfId = const Uuid().v4(); + final tagId = const Uuid().v4(); + final noteId = const Uuid().v4(); + final annotationId = const Uuid().v4(); + final now = DateTime.now().toUtc(); + try { + await first.activateAuthenticated(owner.user.userId); + await second.activateAuthenticated(owner.user.userId); + await outsider.activateAuthenticated(other.user.userId); + await first.upsert( + Book( + id: bookId, + title: 'Live book', + author: 'Author', + addedAt: now, + publicationDate: DateTime.utc(2020), + isPhysical: true, + physicalLocation: 'Room A', + ), + ); + await first.shelves.upsert(Shelf(id: shelfId, name: 'Shelf', createdAt: now, updatedAt: now)); + await first.tags.upsert(Tag(id: tagId, name: 'Topic', colorHex: '#123456', createdAt: now)); + await first.notes.upsert(Note(id: noteId, bookId: bookId, title: 'Note', content: 'Original', createdAt: now)); + await first.annotations.upsert( + Annotation( + id: annotationId, + bookId: bookId, + selectedText: 'Quote', + location: const BookLocation(pageNumber: 3), + note: 'Attached', + createdAt: now, + ), + ); + await first.memberships.updateMemberships(bookIds: {bookId}, shelfIds: [shelfId], tagIds: [tagId]); + + await _eventually( + () async => await second.bookTags.getById('$bookId:$tagId') != null, + 'all domain rows reach device two', + ); + expect((await second.shelves.getById(shelfId))?.name, 'Shelf'); + expect((await second.tags.getById(tagId))?.name, 'Topic'); + expect((await second.annotations.getById(annotationId))?.note, 'Attached'); + expect((await second.getById(bookId))?.publicationDate, DateTime.utc(2020)); + expect(await second.bookShelves.getById('$bookId:$shelfId'), isNotNull); + + await second.setOnline(false); + final baselineNote = (await second.notes.getById(noteId))!; + final baselineBook = (await second.getById(bookId))!; + await second.notes.upsert(baselineNote.copyWith(content: 'Offline edit'), previous: baselineNote); + await second.scopedBooks.update(baselineBook.copyWith(physicalLocation: 'Room B'), previous: baselineBook); + await second.close(); + second = device('two', secondAuth, connect: false); + await second.activateAuthenticated(owner.user.userId); + expect((await second.notes.getById(noteId))?.content, 'Offline edit'); + final currentNote = (await first.notes.getById(noteId))!; + final currentBook = (await first.getById(bookId))!; + await first.notes.upsert(currentNote.copyWith(title: 'Remote title'), previous: currentNote); + await first.scopedBooks.update(currentBook.copyWith(lentTo: 'Reader'), previous: currentBook); + await _eventually(() async => !first.syncState.hasPendingWrites, 'online writes upload'); + await second.setOnline(true); + await _eventually( + () async => (await first.notes.getById(noteId))?.content == 'Offline edit', + 'offline writes upload after restart', + ); + await _eventually( + () async => (await second.notes.getById(noteId))?.title == 'Remote title', + 'different fields merge', + ); + expect((await first.getById(bookId))?.physicalLocation, 'Room B'); + await _eventually(() async => (await second.getById(bookId))?.lentTo == 'Reader', 'promoted book fields merge'); + + await second.setOnline(false); + final offlineNote = (await second.notes.getById(noteId))!; + await second.notes.upsert(offlineNote.copyWith(content: 'Last accepted'), previous: offlineNote); + final onlineNote = (await first.notes.getById(noteId))!; + await first.notes.upsert(onlineNote.copyWith(content: 'First accepted'), previous: onlineNote); + final beforeClear = (await first.getById(bookId))!; + await first.scopedBooks.update(beforeClear.copyWith(clearLentTo: true), previous: beforeClear); + await _eventually(() async => !first.syncState.hasPendingWrites, 'first competing edit uploads'); + await second.setOnline(true); + await _eventually( + () async => (await first.notes.getById(noteId))?.content == 'Last accepted', + 'same-field conflicts follow server acceptance order', + ); + await _eventually(() async => (await second.getById(bookId))?.lentTo == null, 'explicit null clears remotely'); + + final topic = (await first.tags.getById(tagId))!; + await first.tags.upsert(topic.copyWith(name: 'Renamed topic'), previous: topic); + final annotation = (await first.annotations.getById(annotationId))!; + await first.annotations.upsert(annotation.copyWith(note: 'Edited attachment'), previous: annotation); + await first.memberships.updateMemberships(bookIds: {bookId}, shelfIds: [], previousShelfIds: {shelfId}); + await _eventually( + () async => + await second.bookShelves.getById('$bookId:$shelfId') == null && + (await second.tags.getById(tagId))?.name == 'Renamed topic' && + (await second.annotations.getById(annotationId))?.note == 'Edited attachment', + 'membership removal and topic/annotation edits propagate', + ); + + await _eventually(() async => outsider.syncState.lastSyncedAt != null, 'other account checkpoint'); + expect(await outsider.getById(bookId), isNull); + expect(await outsider.notes.getById(noteId), isNull); + expect(await outsider.shelves.getById(shelfId), isNull); + expect(await outsider.tags.getById(tagId), isNull); + expect(await outsider.annotations.getById(annotationId), isNull); + expect(await outsider.bookShelves.getById('$bookId:$shelfId'), isNull); + expect(await outsider.bookTags.getById('$bookId:$tagId'), isNull); + + await second.setOnline(false); + final stale = (await second.notes.getById(noteId))!; + await second.notes.upsert(stale.copyWith(content: 'Stale after deletion'), previous: stale); + await first.delete(bookId); + await _eventually(() async => !first.syncState.hasPendingWrites, 'book deletion uploads'); + await second.setOnline(true); + await _eventually( + () async => await second.getById(bookId) == null && await second.notes.getById(noteId) == null, + 'deletion wins against offline edit', + ); + expect(await second.annotations.getById(annotationId), isNull); + expect(await second.bookShelves.getById('$bookId:$shelfId'), isNull); + expect(await second.bookTags.getById('$bookId:$tagId'), isNull); + await _eventually(() async => !second.syncState.hasPendingWrites, 'stale write queue drains'); + await first.shelves.delete(shelfId); + await first.tags.delete(tagId); + await _eventually(() async => !first.syncState.hasPendingWrites, 'cleanup uploads'); + } finally { + await first.close(); + await second.close(); + await outsider.close(); + for (final repository in [firstAuth, otherAuth]) { + final response = await client.delete( + config.endpoint('/users/me'), + headers: { + 'Authorization': 'Bearer ${repository.tokenStore.accessToken}', + 'Content-Type': 'application/json', + }, + body: jsonEncode({'password': password}), + ); + expect(response.statusCode, 204); + } + client.close(); + await directory.delete(recursive: true); + } + }, + skip: Platform.environment['PAPYRUS_LIVE_SYNC'] != '1', + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/app/test/powersync/library_persistence_test.dart b/app/test/powersync/library_persistence_test.dart new file mode 100644 index 0000000..5b834c3 --- /dev/null +++ b/app/test/powersync/library_persistence_test.dart @@ -0,0 +1,208 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/book_shelf_relation.dart'; +import 'package:papyrus/models/note.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/powersync/papyrus_schema.dart'; +import 'package:powersync/powersync.dart'; + +import 'powersync_service_test.dart' show OfflineConnector; + +void main() { + late Directory directory; + final now = DateTime.utc(2026, 9, 5); + + PapyrusPowerSyncService service() => PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + + setUp(() async => directory = await Directory.systemTemp.createTemp('papyrus-library-')); + tearDown(() async => directory.delete(recursive: true)); + + test('schema expansion keeps queued legacy books and promotes local metadata once', () async { + final dbPath = '${directory.path}/official-one.db'; + final legacy = PowerSyncDatabase( + path: dbPath, + schema: const Schema([ + Table('books', [ + Column.text('title'), + Column.text('author'), + Column.text('added_at'), + Column.text('custom_metadata'), + ]), + ]), + ); + await legacy.initialize(); + await legacy.execute('INSERT INTO books (id, title, author, added_at, custom_metadata) VALUES (?, ?, ?, ?, ?)', [ + 'book', + 'Legacy', + 'Author', + now.toIso8601String(), + jsonEncode({ + 'is_physical': true, + 'file_format': 'epub', + 'physical_location': 'Old room', + 'custom_metadata': {'preserved': 'yes'}, + }), + ]); + final before = await legacy.getAll('SELECT data FROM ps_crud'); + await legacy.close(); + final upgraded = service(); + await upgraded.activateAuthenticated('one'); + final book = (await upgraded.getById('book'))!; + expect(book.isPhysical, isTrue); + expect(book.fileFormat, BookFormat.epub); + expect(book.customMetadata, {'preserved': 'yes'}); + await upgraded.scopedBooks.update(book.copyWith(clearPhysicalLocation: true), previous: book); + await upgraded.close(); + final reopened = service(); + await reopened.activateAuthenticated('one'); + expect((await reopened.getById('book'))?.physicalLocation, isNull); + await reopened.close(); + final inspect = PowerSyncDatabase(path: dbPath, schema: papyrusAccountSchema); + await inspect.initialize(); + final after = await inspect.getAll('SELECT data FROM ps_crud'); + expect(after.map((row) => row['data']), containsAll(before.map((row) => row['data']))); + await inspect.close(); + }); + + test('membership edits are atomic and preserve concurrent additions', () async { + final db = service(); + await db.activateGuest(); + await db.upsert(Book(id: 'book', title: 'Book', author: 'Author', addedAt: now)); + for (final id in ['a', 'b', 'c']) { + await db.shelves.upsert(Shelf(id: id, name: id, createdAt: now, updatedAt: now)); + } + await db.memberships.updateMemberships(bookIds: {'book'}, shelfIds: ['a', 'b']); + await db.memberships.updateMemberships(bookIds: {'book'}, shelfIds: ['c'], previousShelfIds: {'a'}); + expect(await db.bookShelves.getById('book:a'), isNull); + expect(await db.bookShelves.getById('book:b'), isNotNull); + expect(await db.bookShelves.getById('book:c'), isNotNull); + await expectLater(db.memberships.updateMemberships(bookIds: {'book'}, shelfIds: ['missing']), throwsStateError); + expect(await db.bookShelves.getById('book:b'), isNotNull); + expect(await db.bookShelves.getById('book:c'), isNotNull); + await db.close(); + }); + + test('legacy migration skips invalid values and preserves an explicit cleared column', () async { + final dbPath = '${directory.path}/official-one.db'; + final cached = PowerSyncDatabase(path: dbPath, schema: papyrusAccountSchema); + await cached.initialize(); + await cached.execute( + 'INSERT INTO books (id, title, author, added_at, physical_location, custom_metadata) VALUES (?, ?, ?, ?, ?, ?)', + [ + 'book', + 'Book', + 'Author', + now.toIso8601String(), + null, + jsonEncode({ + 'physical_location': 'Stale room', + 'file_size': 'invalid', + 'series_number': 'not a number', + 'custom_metadata': {'preserved': true}, + }), + ], + ); + final count = (await cached.getAll('SELECT data FROM ps_crud')).length; + await cached.close(); + final upgraded = service(); + await upgraded.activateAuthenticated('one'); + final book = (await upgraded.getById('book'))!; + expect(book.physicalLocation, isNull); + expect(book.fileSize, isNull); + expect(book.seriesNumber, isNull); + expect(book.customMetadata, {'preserved': true}); + await upgraded.close(); + final inspect = PowerSyncDatabase(path: dbPath, schema: papyrusAccountSchema); + await inspect.initialize(); + expect((await inspect.getAll('SELECT data FROM ps_crud')).length, count); + await inspect.close(); + }); + + test('shelf deletion reparents children and hierarchy cycles are rejected', () async { + final db = service(); + await db.activateGuest(); + final parent = Shelf(id: 'parent', name: 'Parent', createdAt: now, updatedAt: now); + await db.shelves.upsert(parent); + await db.shelves.upsert(Shelf(id: 'child', name: 'Child', parentShelfId: 'parent', createdAt: now, updatedAt: now)); + await expectLater(db.shelves.upsert(parent.copyWith(parentShelfId: 'child')), throwsStateError); + await db.shelves.delete('parent'); + expect((await db.shelves.getById('child'))?.parentShelfId, isNull); + await db.close(); + }); + + test('all library records persist offline and book deletion removes dependents', () async { + final first = service(); + await first.activateGuest(); + await first.upsert(Book(id: 'book', title: 'Book', author: 'Author', addedAt: now)); + await first.shelves.upsert(Shelf(id: 'shelf', name: 'Shelf', createdAt: now, updatedAt: now)); + await first.tags.upsert(Tag(id: 'tag', name: 'Topic', colorHex: '#123456', createdAt: now)); + await first.notes.upsert(Note(id: 'note', bookId: 'book', title: 'Note', content: 'Content', createdAt: now)); + await first.annotations.upsert( + Annotation( + id: 'annotation', + bookId: 'book', + selectedText: 'Quote', + location: const BookLocation(pageNumber: 5), + createdAt: now, + ), + ); + await first.bookShelves.upsert(BookShelfRelation(bookId: 'book', shelfId: 'shelf', addedAt: now)); + await first.close(); + + final second = service(); + await second.activateGuest(); + expect((await second.shelves.getById('shelf'))?.name, 'Shelf'); + expect((await second.tags.getById('tag'))?.name, 'Topic'); + expect((await second.notes.getById('note'))?.content, 'Content'); + expect((await second.annotations.getById('annotation'))?.location.pageNumber, 5); + expect(await second.bookShelves.getById('book:shelf'), isNotNull); + await second.delete('book'); + expect(await second.notes.getById('note'), isNull); + expect(await second.annotations.getById('annotation'), isNull); + expect(await second.bookShelves.getById('book:shelf'), isNull); + expect(await second.shelves.getById('shelf'), isNotNull); + await second.close(); + }); + + test('stale editor updates only changed fields and can clear nullable values', () async { + final db = service(); + await db.activateGuest(); + final original = Shelf(id: 'shelf', name: 'Old', description: 'Description', createdAt: now, updatedAt: now); + await db.shelves.upsert(original); + await db.shelves.upsert(original.copyWith(name: 'Remote')); + await db.shelves.upsert(original.copyWith(clearDescription: true), previous: original); + final result = await db.shelves.getById('shelf'); + expect(result?.name, 'Remote'); + expect(result?.description, isNull); + await db.close(); + }); + + test('guest and account libraries remain separate and old scope handles cannot write', () async { + final db = service(); + await db.activateGuest(); + final guestShelves = db.shelves; + await guestShelves.upsert(Shelf(id: 'guest', name: 'Guest', createdAt: now, updatedAt: now)); + await db.activateAuthenticated('one'); + expect(await db.shelves.getById('guest'), isNull); + await expectLater(guestShelves.delete('guest'), throwsStateError); + await db.shelves.upsert(Shelf(id: 'account', name: 'Account', createdAt: now, updatedAt: now)); + expect(db.syncState.hasPendingWrites, isTrue); + await db.activateAuthenticated('two'); + expect(await db.shelves.getById('account'), isNull); + await db.activateGuest(); + expect(await db.shelves.getById('guest'), isNotNull); + await db.close(); + }); +} diff --git a/app/test/powersync/papyrus_schema_mode_test.dart b/app/test/powersync/papyrus_schema_mode_test.dart index ad3f381..7655505 100644 --- a/app/test/powersync/papyrus_schema_mode_test.dart +++ b/app/test/powersync/papyrus_schema_mode_test.dart @@ -1,18 +1,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/powersync/papyrus_schema.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; void main() { test('guest books table is local-only', () { - final table = papyrusGuestSchema.tables.single; - - expect(table.name, 'books'); - expect(table.localOnly, isTrue); + expect(papyrusGuestSchema.tables.map((table) => table.name), containsAll(libraryTableNames)); + expect(papyrusGuestSchema.tables.every((table) => table.localOnly), isTrue); }); test('authenticated books table participates in synchronization', () { - final table = papyrusAccountSchema.tables.single; - - expect(table.name, 'books'); - expect(table.localOnly, isFalse); + final tables = papyrusAccountSchema.tables.where((table) => libraryTableNames.contains(table.name)); + expect(tables.length, libraryTableNames.length); + expect(tables.every((table) => !table.localOnly), isTrue); }); } diff --git a/app/test/powersync/powersync_book_mapper_test.dart b/app/test/powersync/powersync_book_mapper_test.dart index f08f4db..cc4a2a1 100644 --- a/app/test/powersync/powersync_book_mapper_test.dart +++ b/app/test/powersync/powersync_book_mapper_test.dart @@ -6,6 +6,80 @@ import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/powersync/powersync_book_mapper.dart'; void main() { + test('device-specific cover paths never enter synchronized fields', () { + for (final url in [ + 'file:///home/user/cover.jpg', + '/home/user/cover.jpg', + 'blob:http://localhost/id', + 'data:image/png;base64,abc', + ]) { + final book = Book(id: 'book', title: 'Title', author: 'Author', addedAt: DateTime.utc(2026), coverUrl: url); + expect(PowerSyncBookMapper.toRow(book)['cover_image_url'], isNull); + } + }); + test('partial uploads preserve absent JSON fields and explicit nulls', () { + expect(PowerSyncBookMapper.decodeUploadData({'title': 'Only title'}), {'title': 'Only title'}); + expect(PowerSyncBookMapper.decodeUploadData({'custom_metadata': null}), {'custom_metadata': null}); + }); + + test('every portable book field survives a complete row round trip', () { + final date = DateTime.utc(2026, 9, 5); + final original = Book( + id: 'book', + title: 'Title', + subtitle: 'Subtitle', + author: 'Author', + coAuthors: const ['Other'], + isbn: '123', + isbn13: '456', + publicationDate: date, + publisher: 'Publisher', + language: 'en', + pageCount: 300, + description: 'Description', + coverUrl: 'https://example.com/cover.png', + fileMediaId: 'file', + coverMediaId: 'cover', + fileFormat: BookFormat.epub, + fileSize: 1024, + fileHash: 'hash', + isPhysical: true, + physicalLocation: 'Room', + lentTo: 'Reader', + lentAt: date, + readingStatus: LibraryReadingStatus.inProgress, + currentPage: 50, + currentPosition: 0.3, + currentCfi: 'epubcfi(/6/2)', + isFavorite: true, + rating: 4, + customMetadata: const { + 'nested': {'value': 1}, + 'list': ['a', 'b'], + }, + seriesId: 'series', + seriesName: 'Series', + seriesNumber: 2.5, + addedAt: date, + startedAt: date, + completedAt: date, + lastReadAt: date, + ); + final restored = PowerSyncBookMapper.fromRow(PowerSyncBookMapper.toRow(original)); + expect(restored.toJson(), original.toJson()); + }); + + test('explicit null promoted fields override legacy metadata', () { + final book = PowerSyncBookMapper.fromRow({ + 'id': 'book', + 'physical_location': null, + 'is_physical': 0, + 'custom_metadata': jsonEncode({'physical_location': 'Old', 'is_physical': true}), + }); + expect(book.physicalLocation, isNull); + expect(book.isPhysical, isFalse); + }); + test('maps Book to synced row without file path or embedded cover bytes', () { final book = Book( id: '11111111-1111-1111-1111-111111111111', @@ -28,7 +102,6 @@ void main() { ); final row = PowerSyncBookMapper.toRow(book); - final metadata = jsonDecode(row['custom_metadata']! as String) as Map; expect(row['cover_image_url'], isNull); expect(row['file_media_id'], '22222222-2222-2222-2222-222222222222'); @@ -37,11 +110,11 @@ void main() { expect(row['co_authors'], jsonEncode(['Co Author'])); expect(row['reading_status'], 'inProgress'); expect(row['is_favorite'], 1); - expect(metadata['file_format'], 'epub'); - expect(metadata['file_size'], 1024); - expect(metadata['file_hash'], 'hash'); - expect(metadata['is_physical'], true); - expect(metadata['physical_location'], 'Shelf'); + expect(row['file_format'], 'epub'); + expect(row['file_size'], 1024); + expect(row['file_hash'], 'hash'); + expect(row['is_physical'], 1); + expect(row['physical_location'], 'Shelf'); }); test('maps synced row to Book', () { diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index dccff20..360b0ac 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -250,18 +250,27 @@ void main() { }); }); + test('remote note-only changes notify the details view', () async { + await provider.loadBook('book-1'); + var notified = false; + provider.addListener(() => notified = true); + await dataStore.updateNote(dataStore.getNote('note-1')!.copyWith(content: 'Remote content')); + expect(notified, isTrue); + expect(provider.notes.single.content, 'Remote content'); + }); + group('note CRUD', () { setUp(() async { await provider.loadBook('book-1'); }); - test('addNote persists to DataStore and notifies', () { + test('addNote persists to DataStore and notifies', () async { final note = buildTestNote(id: 'new-note', bookId: 'book-1', title: 'New Note', content: 'New content'); var notified = false; provider.addListener(() => notified = true); - provider.addNote(note); + await provider.addNote(note); expect(provider.notes.length, 2); expect(provider.notes.any((n) => n.id == 'new-note'), true); @@ -269,7 +278,7 @@ void main() { expect(notified, true); }); - test('updateNote persists updated note to DataStore', () { + test('updateNote persists updated note to DataStore', () async { final updatedNote = buildTestNote( id: 'note-1', bookId: 'book-1', @@ -277,13 +286,13 @@ void main() { content: 'Updated content', ); - provider.updateNote('note-1', updatedNote); + await provider.updateNote('note-1', updatedNote); expect(dataStore.getNote('note-1')!.title, 'Updated Title'); }); - test('deleteNote removes from DataStore', () { - provider.deleteNote('note-1'); + test('deleteNote removes from DataStore', () async { + await provider.deleteNote('note-1'); expect(provider.notes, isEmpty); expect(dataStore.getNote('note-1'), isNull); @@ -348,22 +357,22 @@ void main() { await provider.loadBook('book-1'); }); - test('addAnnotation persists to DataStore', () { + test('addAnnotation persists to DataStore', () async { final annotation = buildTestAnnotation(id: 'new-ann', bookId: 'book-1', selectedText: 'New highlight'); - provider.addAnnotation(annotation); + await provider.addAnnotation(annotation); expect(provider.annotations.length, 2); expect(dataStore.getAnnotation('new-ann'), isNotNull); }); - test('updateAnnotationNote updates the annotation note', () { - provider.updateAnnotationNote('ann-1', 'Updated note'); + test('updateAnnotationNote updates the annotation note', () async { + await provider.updateAnnotationNote('ann-1', 'Updated note'); expect(dataStore.getAnnotation('ann-1')!.note, 'Updated note'); }); - test('updateAnnotation replaces entire annotation', () { + test('updateAnnotation replaces entire annotation', () async { final updated = buildTestAnnotation( id: 'ann-1', bookId: 'book-1', @@ -371,24 +380,24 @@ void main() { color: HighlightColor.pink, ); - provider.updateAnnotation('ann-1', updated); + await provider.updateAnnotation('ann-1', updated); expect(dataStore.getAnnotation('ann-1')!.selectedText, 'Replaced text'); expect(dataStore.getAnnotation('ann-1')!.color, HighlightColor.pink); }); - test('deleteAnnotation removes from DataStore', () { - provider.deleteAnnotation('ann-1'); + test('deleteAnnotation removes from DataStore', () async { + await provider.deleteAnnotation('ann-1'); expect(provider.annotations, isEmpty); expect(dataStore.getAnnotation('ann-1'), isNull); }); - test('updateAnnotationNote does nothing for nonexistent annotation', () { + test('updateAnnotationNote does nothing for nonexistent annotation', () async { var notified = false; provider.addListener(() => notified = true); - provider.updateAnnotationNote('nonexistent', 'A note'); + await provider.updateAnnotationNote('nonexistent', 'A note'); expect(notified, false); }); diff --git a/app/test/providers/book_edit_persistence_test.dart b/app/test/providers/book_edit_persistence_test.dart new file mode 100644 index 0000000..0e99a6a --- /dev/null +++ b/app/test/providers/book_edit_persistence_test.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/book_edit_provider.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; + +import '../helpers/test_helpers.dart'; +import '../powersync/powersync_service_test.dart' show OfflineConnector; + +void main() { + late Directory directory; + late PapyrusPowerSyncService service; + late DataStore store; + late BookEditProvider editor; + + setUp(() async { + directory = await Directory.systemTemp.createTemp('papyrus-editor-'); + service = PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + await service.activateGuest(); + await service.upsert(buildTestBook(id: 'book', title: 'Original', author: 'Original author')); + store = DataStore(bookRepository: service); + editor = BookEditProvider()..setDataStore(store); + await editor.loadBook('book'); + }); + + tearDown(() async { + editor.dispose(); + await store.disposeBookRepository(); + await service.close(); + await directory.delete(recursive: true); + }); + + test('saving a stale editor preserves an unrelated remote field', () async { + final original = editor.originalBook!; + editor.updateTitle('Edited title'); + await service.scopedBooks.update(original.copyWith(author: 'Remote author'), previous: original); + + expect(await editor.save(), isTrue); + final saved = await service.getById('book'); + expect(saved?.title, 'Edited title'); + expect(saved?.author, 'Remote author'); + }); + + test('an editor opened in guest scope cannot save into another account', () async { + editor.updateTitle('Stale edit'); + await service.activateAuthenticated('other-account'); + await service.upsert(buildTestBook(id: 'book', title: 'Other account')); + + expect(await editor.save(), isFalse); + expect(editor.error, contains('Failed to save book')); + expect((await service.getById('book'))?.title, 'Other account'); + await service.activateGuest(); + expect((await service.getById('book'))?.title, 'Original'); + }); +} diff --git a/app/test/widgets/persistent_editor_test.dart b/app/test/widgets/persistent_editor_test.dart new file mode 100644 index 0000000..60002c5 --- /dev/null +++ b/app/test/widgets/persistent_editor_test.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/widgets/book_details/note_dialog.dart'; +import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; +import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; +import 'package:papyrus/widgets/topics/add_topic_sheet.dart'; +import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + for (final kind in ['shelf', 'topic', 'memberships', 'topic memberships', 'note']) { + for (final fails in [false, true]) { + testWidgets('$kind waits for persistence and ${fails ? 'stays open on failure' : 'closes on success'}', ( + tester, + ) async { + final write = Completer(); + var calls = 0; + Future save() { + calls++; + return write.future; + } + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: DataStore(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return TextButton( + onPressed: () { + switch (kind) { + case 'shelf': + AddShelfSheet.show(context, onSave: (_, _, _, _) => save()); + case 'topic': + AddTopicSheet.show(context, onSave: (_, _, _) => save()); + case 'memberships': + MoveToShelfSheet.show( + context, + book: buildTestBook(id: 'book'), + onSave: (_) => save(), + ); + case 'topic memberships': + ManageTopicsSheet.show( + context, + book: buildTestBook(id: 'book'), + onSave: (_) => save(), + ); + case 'note': + NoteDialog.show(context, bookId: 'book', onSave: (_) => save()); + } + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + if (kind == 'shelf' || kind == 'topic' || kind == 'note') { + await tester.enterText(find.byType(TextFormField).first, 'Name'); + } + if (kind == 'note') { + await tester.enterText(find.byKey(const Key('note-content-field')), 'Content'); + } + await tester.pump(); + final button = find.widgetWithText( + FilledButton, + kind == 'shelf' + ? 'Create shelf' + : kind == 'topic' + ? 'Create topic' + : 'Save', + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pump(); + expect(calls, 1); + expect(button, findsOneWidget); + expect(tester.widget(button).onPressed, isNull); + + if (fails) { + write.completeError(StateError('Disk write failed')); + } else { + write.complete(); + } + await tester.pumpAndSettle(); + expect(button, fails ? findsOneWidget : findsNothing); + if (fails) { + expect(find.text('Could not save changes. Please try again.'), findsOneWidget); + expect(tester.widget(button).onPressed, isNotNull); + } + }); + } + } +} From 355f8bff0534f3b73fecbbbb5df5351b4c41947a Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 6 Sep 2026 00:32:15 +0300 Subject: [PATCH 2/3] Fix annotation editing and favorites and persist bookmarks --- app/lib/data/data_store.dart | 35 ++-- .../data/repositories/library_repository.dart | 4 + app/lib/main.dart | 2 +- app/lib/pages/annotations_page.dart | 32 ++-- app/lib/pages/book_details_page.dart | 74 ++++---- app/lib/pages/bookmarks_page.dart | 57 +++--- app/lib/powersync/library_database.dart | 4 +- app/lib/powersync/library_row_mapper.dart | 18 +- app/lib/powersync/papyrus_schema.dart | 18 ++ app/lib/powersync/powersync_service.dart | 4 + app/lib/providers/book_details_provider.dart | 42 +++-- app/lib/providers/bookmarks_provider.dart | 39 +++-- app/lib/providers/library_provider.dart | 20 ++- app/lib/utils/book_actions.dart | 14 +- app/lib/utils/bulk_book_actions.dart | 22 ++- .../book_details/annotation_action_sheet.dart | 10 +- .../book_details/annotation_dialog.dart | 8 +- .../widgets/book_details/bookmark_dialog.dart | 23 ++- .../bookmarks/bookmark_action_sheet.dart | 105 +++++++++--- app/lib/widgets/library/book_grid.dart | 3 +- app/test/pages/annotation_edit_test.dart | 70 ++++++++ .../powersync/bookmark_persistence_test.dart | 78 +++++++++ .../powersync/library_live_sync_test.dart | 68 +++++++- .../providers/book_details_provider_test.dart | 26 +-- .../providers/bookmark_persistence_test.dart | 94 ++++++++++ .../library_favorite_persistence_test.dart | 54 ++++++ .../book_details/bookmark_dialog_test.dart | 162 ++++++++++++++++++ 27 files changed, 911 insertions(+), 175 deletions(-) create mode 100644 app/test/pages/annotation_edit_test.dart create mode 100644 app/test/powersync/bookmark_persistence_test.dart create mode 100644 app/test/providers/bookmark_persistence_test.dart create mode 100644 app/test/providers/library_favorite_persistence_test.dart create mode 100644 app/test/widgets/book_details/bookmark_dialog_test.dart diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index d1d0b1b..296ca63 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -74,6 +74,9 @@ class DataStore extends ChangeNotifier { _annotations ..clear() ..addEntries(snapshot.annotations.map((value) => MapEntry(value.id, value))); + _bookmarks + ..clear() + ..addEntries(snapshot.bookmarks.map((value) => MapEntry(value.id, value))); _bookShelfRelations ..clear() ..addAll(snapshot.bookShelves); @@ -511,20 +514,28 @@ class DataStore extends ChangeNotifier { return _bookmarks.values.where((b) => b.bookId == bookId).toList(); } - void addBookmark(Bookmark bookmark) { - _bookmarks[bookmark.id] = bookmark; - notifyListeners(); - } + Future addBookmark(Bookmark bookmark, {Bookmark? previous, EntityRepository? repository}) => + _saveEntity( + repository ?? libraryRepository?.bookmarks, + bookmark, + bookmark.id, + previous, + (saved) => _bookmarks[bookmark.id] = saved, + ); - void updateBookmark(Bookmark bookmark) { - _bookmarks[bookmark.id] = bookmark; - notifyListeners(); - } + Future updateBookmark(Bookmark bookmark, {Bookmark? previous, EntityRepository? repository}) => + _saveEntity( + repository ?? libraryRepository?.bookmarks, + bookmark, + bookmark.id, + previous ?? _bookmarks[bookmark.id], + (saved) => _bookmarks[bookmark.id] = saved, + ); - void deleteBookmark(String id) { - _bookmarks.remove(id); - notifyListeners(); - } + Future deleteBookmark(String id, {EntityRepository? repository}) => + _deleteEntity(repository ?? libraryRepository?.bookmarks, id, () { + _bookmarks.remove(id); + }); // ============================================================ // Reading Session CRUD diff --git a/app/lib/data/repositories/library_repository.dart b/app/lib/data/repositories/library_repository.dart index c801bef..f7d6753 100644 --- a/app/lib/data/repositories/library_repository.dart +++ b/app/lib/data/repositories/library_repository.dart @@ -1,6 +1,7 @@ import 'package:papyrus/data/repositories/book_repository.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/models/book_shelf_relation.dart'; import 'package:papyrus/models/book_tag_relation.dart'; import 'package:papyrus/models/note.dart'; @@ -25,6 +26,7 @@ abstract interface class LibraryRepository { EntityRepository get tags; EntityRepository get notes; EntityRepository get annotations; + EntityRepository get bookmarks; EntityRepository get bookShelves; EntityRepository get bookTags; LibraryMembershipWriter get memberships; @@ -47,6 +49,7 @@ class LibrarySnapshot { final List tags; final List notes; final List annotations; + final List bookmarks; final List bookShelves; final List bookTags; @@ -56,6 +59,7 @@ class LibrarySnapshot { this.tags = const [], this.notes = const [], this.annotations = const [], + this.bookmarks = const [], this.bookShelves = const [], this.bookTags = const [], }); diff --git a/app/lib/main.dart b/app/lib/main.dart index d3871bf..0e35035 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -411,7 +411,7 @@ class _PapyrusState extends State { ChangeNotifierProvider.value(value: _acquisitionAvailabilityProvider), _acquisitionDownloadsComposition.providerRegistration(), ChangeNotifierProvider(create: (_) => SidebarProvider()), - ChangeNotifierProvider(create: (_) => LibraryProvider()), + ChangeNotifierProvider(create: (_) => LibraryProvider(dataStore: _dataStore)), ChangeNotifierProvider.value(value: _preferencesProvider), ], child: Consumer( diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index fc3cb3f..b955eb4 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.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/providers/annotations_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -8,6 +9,7 @@ import 'package:papyrus/widgets/shared/book_group_header.dart'; import 'package:papyrus/widgets/annotations/annotation_action_sheet.dart'; import 'package:papyrus/widgets/book_details/annotation_action_sheet.dart'; import 'package:papyrus/widgets/book_details/annotation_card.dart'; +import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:provider/provider.dart'; @@ -323,36 +325,36 @@ class _AnnotationsPageState extends State { } void _onAnnotationActions(BuildContext context, AnnotationsProvider provider, Annotation annotation) async { + final repository = context.read().libraryRepository?.annotations; final action = await AnnotationActionSheet.show(context, annotation: annotation); if (action == null || !mounted) return; switch (action) { - case AnnotationAction.editNote: - _onEditAnnotationNote(provider, annotation); + case AnnotationAction.edit: + _onEditAnnotation(annotation, repository); case AnnotationAction.delete: - _onDeleteAnnotation(provider, annotation); + _onDeleteAnnotation(provider, annotation, repository); } } - void _onEditAnnotationNote(AnnotationsProvider provider, Annotation annotation) async { - final repository = context.read().libraryRepository?.annotations; - await AnnotationNoteSheet.show( + void _onEditAnnotation(Annotation annotation, EntityRepository? repository) async { + final store = context.read(); + await AnnotationDialog.show( context, - annotation: annotation, - onSave: (note) => provider.updateAnnotationNote( - annotation.id, - note.isEmpty ? null : note, - previous: annotation, - repository: repository, - ), + bookId: annotation.bookId, + existingAnnotation: annotation, + onSave: (updated) => store.updateAnnotation(updated, previous: annotation, repository: repository), ); if (!mounted) return; } - void _onDeleteAnnotation(AnnotationsProvider provider, Annotation annotation) async { + void _onDeleteAnnotation( + AnnotationsProvider provider, + Annotation annotation, + EntityRepository? repository, + ) async { final bookTitle = provider.getBookTitle(annotation.bookId); - final repository = context.read().libraryRepository?.annotations; final confirmed = await DeleteAnnotationDialog.show(context, annotation: annotation, bookTitle: bookTitle); if (confirmed && mounted) { try { diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 4db26ed..7900afb 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/annotation.dart'; @@ -520,14 +521,15 @@ class _BookDetailsPageState extends State with SingleTickerProv void _onAddBookmark() async { if (_provider.book == null) return; + final repository = context.read().libraryRepository?.bookmarks; final bookmark = await BookmarkDialog.show( context, bookId: _provider.book!.id, pageCount: _provider.book!.pageCount, + onSave: (bookmark) => _provider.addBookmark(bookmark, repository: repository), ); if (bookmark != null && mounted) { - _provider.addBookmark(bookmark); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Bookmark added'))); } } @@ -597,35 +599,31 @@ class _BookDetailsPageState extends State with SingleTickerProv } void _onAnnotationActions(Annotation annotation) async { + final repository = context.read().libraryRepository?.annotations; final action = await AnnotationActionSheet.show(context, annotation: annotation); if (action == null || !mounted) return; switch (action) { - case AnnotationAction.editNote: - _onEditAnnotationNote(annotation); + case AnnotationAction.edit: + _onEditAnnotation(annotation, repository); case AnnotationAction.delete: - _onDeleteAnnotation(annotation); + _onDeleteAnnotation(annotation, repository); } } - void _onEditAnnotationNote(Annotation annotation) async { - final repository = context.read().libraryRepository?.annotations; - await annotation_sheets.AnnotationNoteSheet.show( + void _onEditAnnotation(Annotation annotation, EntityRepository? repository) async { + await AnnotationDialog.show( context, - annotation: annotation, - onSave: (note) => _provider.updateAnnotationNote( - annotation.id, - note.isEmpty ? null : note, - previous: annotation, - repository: repository, - ), + bookId: annotation.bookId, + existingAnnotation: annotation, + onSave: (updated) => + _provider.updateAnnotation(annotation.id, updated, previous: annotation, repository: repository), ); if (!mounted) return; } - void _onDeleteAnnotation(Annotation annotation) async { - final repository = context.read().libraryRepository?.annotations; + void _onDeleteAnnotation(Annotation annotation, EntityRepository? repository) async { final confirmed = await annotation_sheets.DeleteAnnotationDialog.show( context, annotation: annotation, @@ -646,45 +644,49 @@ class _BookDetailsPageState extends State with SingleTickerProv } void _onBookmarkActions(Bookmark bookmark) async { + final repository = context.read().libraryRepository?.bookmarks; final action = await BookmarkActionSheet.show(context, bookmark: bookmark); if (action == null || !mounted) return; switch (action) { case BookmarkAction.editNote: - _onEditBookmarkNote(bookmark); + _onEditBookmarkNote(bookmark, repository); case BookmarkAction.changeColor: - _onChangeBookmarkColor(bookmark); + _onChangeBookmarkColor(bookmark, repository); case BookmarkAction.delete: - _onDeleteBookmark(bookmark); + _onDeleteBookmark(bookmark, repository); } } - void _onEditBookmarkNote(Bookmark bookmark) async { - final note = await BookmarkNoteSheet.show(context, bookmark: bookmark); - if (!mounted) return; - - if (note != null) { - _provider.updateBookmarkNote(bookmark.id, note.isEmpty ? null : note); - } + void _onEditBookmarkNote(Bookmark bookmark, EntityRepository? repository) async { + await BookmarkNoteSheet.show( + context, + bookmark: bookmark, + onSave: (note) => _provider.updateBookmarkNote( + bookmark.id, + note.isEmpty ? null : note, + previous: bookmark, + repository: repository, + ), + ); } - void _onChangeBookmarkColor(Bookmark bookmark) async { - final colorHex = await BookmarkColorSheet.show(context, bookmark: bookmark); - if (colorHex != null && mounted) { - _provider.updateBookmarkColor(bookmark.id, colorHex); - } + void _onChangeBookmarkColor(Bookmark bookmark, EntityRepository? repository) async { + await BookmarkColorSheet.show( + context, + bookmark: bookmark, + onSave: (color) => _provider.updateBookmarkColor(bookmark.id, color, previous: bookmark, repository: repository), + ); } - void _onDeleteBookmark(Bookmark bookmark) async { - final confirmed = await DeleteBookmarkDialog.show( + void _onDeleteBookmark(Bookmark bookmark, EntityRepository? repository) async { + await DeleteBookmarkDialog.show( context, bookmark: bookmark, bookTitle: _provider.book?.title ?? '', + onDelete: () => _provider.deleteBookmark(bookmark.id, repository: repository), ); - if (confirmed && mounted) { - _provider.deleteBookmark(bookmark.id); - } } Future _confirmDeleteBook() async { diff --git a/app/lib/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index 4d99e63..6c36a63 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/providers/bookmarks_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -336,41 +337,57 @@ class _BookmarksPageState extends State { } void _onBookmarkActions(BuildContext context, BookmarksProvider provider, Bookmark bookmark) async { + final repository = context.read().libraryRepository?.bookmarks; final action = await BookmarkActionSheet.show(context, bookmark: bookmark); if (action == null || !mounted) return; switch (action) { case BookmarkAction.editNote: - _onEditBookmarkNote(provider, bookmark); + _onEditBookmarkNote(provider, bookmark, repository); case BookmarkAction.changeColor: - _onChangeBookmarkColor(provider, bookmark); + _onChangeBookmarkColor(provider, bookmark, repository); case BookmarkAction.delete: - _onDeleteBookmark(provider, bookmark); + _onDeleteBookmark(provider, bookmark, repository); } } - void _onEditBookmarkNote(BookmarksProvider provider, Bookmark bookmark) async { - final note = await BookmarkNoteSheet.show(context, bookmark: bookmark); - if (!mounted) return; - - if (note != null) { - provider.updateBookmarkNote(bookmark.id, note.isEmpty ? null : note); - } + void _onEditBookmarkNote( + BookmarksProvider provider, + Bookmark bookmark, + EntityRepository? repository, + ) async { + await BookmarkNoteSheet.show( + context, + bookmark: bookmark, + onSave: (note) => provider.updateBookmarkNote( + bookmark.id, + note.isEmpty ? null : note, + previous: bookmark, + repository: repository, + ), + ); } - void _onChangeBookmarkColor(BookmarksProvider provider, Bookmark bookmark) async { - final colorHex = await BookmarkColorSheet.show(context, bookmark: bookmark); - if (colorHex != null && mounted) { - provider.updateBookmarkColor(bookmark.id, colorHex); - } + void _onChangeBookmarkColor( + BookmarksProvider provider, + Bookmark bookmark, + EntityRepository? repository, + ) async { + await BookmarkColorSheet.show( + context, + bookmark: bookmark, + onSave: (color) => provider.updateBookmarkColor(bookmark.id, color, previous: bookmark, repository: repository), + ); } - void _onDeleteBookmark(BookmarksProvider provider, Bookmark bookmark) async { + void _onDeleteBookmark(BookmarksProvider provider, Bookmark bookmark, EntityRepository? repository) async { final bookTitle = provider.getBookTitle(bookmark.bookId); - final confirmed = await DeleteBookmarkDialog.show(context, bookmark: bookmark, bookTitle: bookTitle); - if (confirmed && mounted) { - provider.deleteBookmark(bookmark.id); - } + await DeleteBookmarkDialog.show( + context, + bookmark: bookmark, + bookTitle: bookTitle, + onDelete: () => provider.deleteBookmark(bookmark.id, repository: repository), + ); } } diff --git a/app/lib/powersync/library_database.dart b/app/lib/powersync/library_database.dart index 780f210..d552010 100644 --- a/app/lib/powersync/library_database.dart +++ b/app/lib/powersync/library_database.dart @@ -19,6 +19,7 @@ class LibraryDatabase implements LibraryMembershipWriter { late final tags = SqlEntityRepository(this, tagRowMapper); late final notes = SqlEntityRepository(this, noteRowMapper); late final annotations = SqlEntityRepository(this, annotationRowMapper); + late final bookmarks = SqlEntityRepository(this, bookmarkRowMapper); late final bookShelves = SqlEntityRepository(this, bookShelfRowMapper); late final bookTags = SqlEntityRepository(this, bookTagRowMapper); late final books = ScopedBooks(this); @@ -94,7 +95,7 @@ class LibraryDatabase implements LibraryMembershipWriter { Future delete(String table, String id) => write((tx) async { if (table == 'books') { - for (final dependent in ['notes', 'annotations', 'book_shelves', 'book_tags']) { + for (final dependent in ['notes', 'annotations', 'bookmarks', 'book_shelves', 'book_tags']) { await tx.execute('DELETE FROM $dependent WHERE book_id = ?', [id]); } } else if (table == 'shelves') { @@ -159,6 +160,7 @@ class LibraryDatabase implements LibraryMembershipWriter { tags: await rows(tagRowMapper), notes: await rows(noteRowMapper), annotations: await rows(annotationRowMapper), + bookmarks: await rows(bookmarkRowMapper), bookShelves: await rows(bookShelfRowMapper), bookTags: await rows(bookTagRowMapper), ); diff --git a/app/lib/powersync/library_row_mapper.dart b/app/lib/powersync/library_row_mapper.dart index 9a1956a..efa9400 100644 --- a/app/lib/powersync/library_row_mapper.dart +++ b/app/lib/powersync/library_row_mapper.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/models/book_shelf_relation.dart'; import 'package:papyrus/models/book_tag_relation.dart'; import 'package:papyrus/models/note.dart'; @@ -15,7 +16,22 @@ class LibraryRowMapper { const LibraryRowMapper(this.table, this.toRow, this.fromRow); } -const libraryTableNames = ['books', 'shelves', 'tags', 'notes', 'annotations', 'book_shelves', 'book_tags']; +const libraryTableNames = [ + 'books', + 'shelves', + 'tags', + 'notes', + 'annotations', + 'bookmarks', + 'book_shelves', + 'book_tags', +]; + +final bookmarkRowMapper = LibraryRowMapper( + 'bookmarks', + (value) => encodeLibraryRow(value.toJson()), + (row) => Bookmark.fromJson(decodeLibraryRow(row)), +); final shelfRowMapper = LibraryRowMapper('shelves', (shelf) { final row = Map.from(shelf.toJson()); diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index 88d57e7..2aa3fa4 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -93,6 +93,22 @@ const _annotationsColumns = [ Column.text('updated_at'), ]; +const _bookmarkColumns = [ + Column.text('owner_user_id'), + Column.text('book_id'), + Column.real('position'), + Column.integer('page_number'), + Column.text('chapter_title'), + Column.text('note'), + Column.text('color_hex'), + Column.text('created_at'), + Column.text('updated_at'), +]; + +const _bookmarkIndexes = [ + Index('bookmarks_book_id', [IndexedColumn('book_id')]), +]; + const _bookShelvesColumns = [ Column.text('owner_user_id'), Column.text('book_id'), @@ -114,6 +130,7 @@ const papyrusAccountSchema = Schema([ Table('tags', _tagsColumns), Table('notes', _notesColumns), Table('annotations', _annotationsColumns), + Table('bookmarks', _bookmarkColumns, indexes: _bookmarkIndexes), Table('book_shelves', _bookShelvesColumns), Table('book_tags', _bookTagsColumns), Table.localOnly('library_migrations', [Column.integer('version')]), @@ -125,6 +142,7 @@ const papyrusGuestSchema = Schema([ Table.localOnly('tags', _tagsColumns), Table.localOnly('notes', _notesColumns), Table.localOnly('annotations', _annotationsColumns), + Table.localOnly('bookmarks', _bookmarkColumns, indexes: _bookmarkIndexes), Table.localOnly('book_shelves', _bookShelvesColumns), Table.localOnly('book_tags', _bookTagsColumns), Table.localOnly('library_migrations', [Column.integer('version')]), diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index bbb2cab..af788c8 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.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/book_shelf_relation.dart'; import 'package:papyrus/models/book_tag_relation.dart'; import 'package:papyrus/models/note.dart'; @@ -77,6 +78,8 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { @override EntityRepository get annotations => _activeLibrary.annotations; @override + EntityRepository get bookmarks => _activeLibrary.bookmarks; + @override EntityRepository get bookShelves => _activeLibrary.bookShelves; @override EntityRepository get bookTags => _activeLibrary.bookTags; @@ -270,6 +273,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { _setSyncState(const SyncState()); _setBookMetadataSyncState(const BookMetadataSyncState()); } + await _refreshPendingWrites(); } void _watchBooks(PowerSyncDatabase database) { diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 5f292ea..2faa5c3 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -187,25 +187,40 @@ class BookDetailsProvider extends ChangeNotifier { } /// Update a bookmark's note. Persists to DataStore. - void updateBookmarkNote(String bookmarkId, String? note) { - final bookmark = _dataStore?.getBookmark(bookmarkId); + Future updateBookmarkNote( + String bookmarkId, + String? note, { + Bookmark? previous, + EntityRepository? repository, + }) async { + final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); if (bookmark == null || _dataStore == null) return; - _dataStore!.updateBookmark(bookmark.copyWith(note: note)); - notifyListeners(); + await _dataStore!.updateBookmark( + bookmark.copyWith(note: note), + previous: bookmark, + repository: repository, + ); } /// Update a bookmark's color. Persists to DataStore. - void updateBookmarkColor(String bookmarkId, String colorHex) { - final bookmark = _dataStore?.getBookmark(bookmarkId); + Future updateBookmarkColor( + String bookmarkId, + String colorHex, { + Bookmark? previous, + EntityRepository? repository, + }) async { + final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); if (bookmark == null || _dataStore == null) return; - _dataStore!.updateBookmark(bookmark.copyWith(colorHex: colorHex)); - notifyListeners(); + await _dataStore!.updateBookmark( + bookmark.copyWith(colorHex: colorHex), + previous: bookmark, + repository: repository, + ); } /// Delete a bookmark. Persists to DataStore. - void deleteBookmark(String bookmarkId) { - _dataStore?.deleteBookmark(bookmarkId); - notifyListeners(); + Future deleteBookmark(String bookmarkId, {EntityRepository? repository}) async { + await _dataStore?.deleteBookmark(bookmarkId, repository: repository); } /// Add a new annotation. Persists to DataStore. @@ -293,11 +308,10 @@ class BookDetailsProvider extends ChangeNotifier { } /// Add a new bookmark. Persists to DataStore. - void addBookmark(Bookmark bookmark) { + Future addBookmark(Bookmark bookmark, {EntityRepository? repository}) async { if (_dataStore != null) { - _dataStore!.addBookmark(bookmark); + await _dataStore!.addBookmark(bookmark, repository: repository); } - notifyListeners(); } /// Clear the current book state. diff --git a/app/lib/providers/bookmarks_provider.dart b/app/lib/providers/bookmarks_provider.dart index 405b735..d04a64f 100644 --- a/app/lib/providers/bookmarks_provider.dart +++ b/app/lib/providers/bookmarks_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/bookmark.dart'; /// Sort options for bookmarks. @@ -134,20 +135,38 @@ class BookmarksProvider extends ChangeNotifier { // CRUD (delegated to DataStore) // ============================================================================ - void updateBookmarkNote(String bookmarkId, String? note) { - final bookmark = _dataStore?.getBookmark(bookmarkId); + Future updateBookmarkNote( + String bookmarkId, + String? note, { + Bookmark? previous, + EntityRepository? repository, + }) async { + final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); if (bookmark == null || _dataStore == null) return; - _dataStore!.updateBookmark(bookmark.copyWith(note: note)); - } - - void updateBookmarkColor(String bookmarkId, String colorHex) { - final bookmark = _dataStore?.getBookmark(bookmarkId); + await _dataStore!.updateBookmark( + bookmark.copyWith(note: note), + previous: bookmark, + repository: repository, + ); + } + + Future updateBookmarkColor( + String bookmarkId, + String colorHex, { + Bookmark? previous, + EntityRepository? repository, + }) async { + final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); if (bookmark == null || _dataStore == null) return; - _dataStore!.updateBookmark(bookmark.copyWith(colorHex: colorHex)); + await _dataStore!.updateBookmark( + bookmark.copyWith(colorHex: colorHex), + previous: bookmark, + repository: repository, + ); } - void deleteBookmark(String bookmarkId) { - _dataStore?.deleteBookmark(bookmarkId); + Future deleteBookmark(String bookmarkId, {EntityRepository? repository}) async { + await _dataStore?.deleteBookmark(bookmarkId, repository: repository); } // ============================================================================ diff --git a/app/lib/providers/library_provider.dart b/app/lib/providers/library_provider.dart index ac557d0..a8dd0a7 100644 --- a/app/lib/providers/library_provider.dart +++ b/app/lib/providers/library_provider.dart @@ -9,9 +9,13 @@ import 'package:papyrus/utils/book_language.dart'; class LibraryProvider extends ChangeNotifier { final LibraryProvider? _favoriteDelegate; + final DataStore? _dataStore; - LibraryProvider({LibraryProvider? favoriteDelegate}) : _favoriteDelegate = favoriteDelegate { + LibraryProvider({LibraryProvider? favoriteDelegate, DataStore? dataStore}) + : _favoriteDelegate = favoriteDelegate, + _dataStore = dataStore { _favoriteDelegate?.addListener(_onFavoriteDelegateChanged); + _dataStore?.addListener(_onFavoriteDelegateChanged); } String _searchQuery = ''; @@ -303,14 +307,23 @@ class LibraryProvider extends ChangeNotifier { return favoriteDelegate.isBookFavorite(bookId, originalFavorite); } + if (_dataStore != null) return _dataStore.getBook(bookId)?.isFavorite ?? originalFavorite; return _favoriteOverrides[bookId] ?? originalFavorite; } /// Toggle the favorite status of a book. - void toggleFavorite(String bookId, bool currentFavorite) { + Future toggleFavorite(String bookId, bool currentFavorite) async { final favoriteDelegate = _favoriteDelegate; if (favoriteDelegate != null) { - favoriteDelegate.toggleFavorite(bookId, currentFavorite); + await favoriteDelegate.toggleFavorite(bookId, currentFavorite); + return; + } + + final store = _dataStore; + if (store != null) { + final book = store.getBook(bookId); + if (book == null) return; + await store.updateBookAndWait(book.copyWith(isFavorite: !currentFavorite), previous: book); return; } @@ -389,6 +402,7 @@ class LibraryProvider extends ChangeNotifier { @override void dispose() { _favoriteDelegate?.removeListener(_onFavoriteDelegateChanged); + _dataStore?.removeListener(_onFavoriteDelegateChanged); super.dispose(); } } diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index a502897..b85b9c9 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -35,7 +35,7 @@ void showBookContextMenu({required BuildContext context, required Book book, Off libraryProvider.enterSelectionMode(book.id); }, onFavoriteToggle: () { - libraryProvider.toggleFavorite(book.id, isFavorite); + toggleBookFavorite(context, book.id, isFavorite); }, onEdit: () { context.goNamed('BOOK_EDIT', pathParameters: {'bookId': book.id}); @@ -73,6 +73,18 @@ void showBookContextMenu({required BuildContext context, required Book book, Off ); } +Future toggleBookFavorite(BuildContext context, String bookId, bool currentFavorite) async { + try { + await context.read().toggleFavorite(bookId, currentFavorite); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not save favorite. Please try again.'))); + } + } +} + Future _downloadBookFile(BuildContext context, Book book) async { final messenger = ScaffoldMessenger.of(context); final importService = context.read(); diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index 58b71a1..5e983b8 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -46,24 +46,26 @@ void bulkChangeStatus(DataStore dataStore, Set bookIds, LibraryReadingSt /// Toggle favorite for all selected books. /// If any are not favorited, sets all to favorite; otherwise un-favorites all. -void bulkToggleFavorite(LibraryProvider libraryProvider, DataStore dataStore, Set bookIds) { +Future bulkToggleFavorite(LibraryProvider libraryProvider, DataStore dataStore, Set bookIds) async { final allFavorite = bookIds.every((id) { final book = dataStore.getBook(id); return book != null && libraryProvider.isBookFavorite(id, book.isFavorite); }); + final writes = >[]; for (final bookId in bookIds) { final book = dataStore.getBook(bookId); if (book == null) continue; final currentFav = libraryProvider.isBookFavorite(bookId, book.isFavorite); if (allFavorite) { // Un-favorite all - if (currentFav) libraryProvider.toggleFavorite(bookId, true); + if (currentFav) writes.add(libraryProvider.toggleFavorite(bookId, true)); } else { // Favorite all - if (!currentFav) libraryProvider.toggleFavorite(bookId, false); + if (!currentFav) writes.add(libraryProvider.toggleFavorite(bookId, false)); } } + await Future.wait(writes); } /// Delete all selected books. @@ -124,10 +126,18 @@ void handleBulkChangeStatus(BuildContext context, LibraryProvider libraryProvide } /// Toggle favorite status for all selected books. -void handleBulkToggleFavorite(BuildContext context, LibraryProvider libraryProvider) { +Future handleBulkToggleFavorite(BuildContext context, LibraryProvider libraryProvider) async { final dataStore = context.read(); - bulkToggleFavorite(libraryProvider, dataStore, libraryProvider.selectedBookIds); - libraryProvider.exitSelectionMode(); + try { + await bulkToggleFavorite(libraryProvider, dataStore, libraryProvider.selectedBookIds); + libraryProvider.exitSelectionMode(); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not save favorites. Please try again.'))); + } + } } /// Show a confirmation dialog and delete all selected books. diff --git a/app/lib/widgets/book_details/annotation_action_sheet.dart b/app/lib/widgets/book_details/annotation_action_sheet.dart index 053c611..ea789d3 100644 --- a/app/lib/widgets/book_details/annotation_action_sheet.dart +++ b/app/lib/widgets/book_details/annotation_action_sheet.dart @@ -3,9 +3,9 @@ import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; /// Result of annotation action sheet selection. -enum AnnotationAction { editNote, delete } +enum AnnotationAction { edit, delete } -/// Bottom sheet for annotation actions (edit note, delete). +/// Bottom sheet for annotation actions (edit, delete). class AnnotationActionSheet extends StatelessWidget { final Annotation annotation; @@ -50,11 +50,11 @@ class AnnotationActionSheet extends StatelessWidget { const SizedBox(height: Spacing.sm), const Divider(), - // Edit note action + // Edit annotation action ListTile( leading: Icon(Icons.edit_outlined, color: colorScheme.onSurface), - title: const Text('Edit note'), - onTap: () => Navigator.of(context).pop(AnnotationAction.editNote), + title: const Text('Edit annotation'), + onTap: () => Navigator.of(context).pop(AnnotationAction.edit), ), // Delete action diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index bd3b1f7..27e3bf0 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -79,7 +79,12 @@ class _AnnotationDialogState extends State with PersistentSave bookId: widget.bookId, selectedText: _textController.text.trim(), color: _selectedColor, - location: BookLocation(pageNumber: page, chapterTitle: chapter.isNotEmpty ? chapter : null), + location: BookLocation( + pageNumber: page, + 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, @@ -207,6 +212,7 @@ class _AnnotationDialogState extends State with PersistentSave children: HighlightColor.values.map((color) { final isSelected = color == _selectedColor; return GestureDetector( + key: Key('annotation-color-${color.name}'), onTap: () => setState(() => _selectedColor = color), child: Container( width: 36, diff --git a/app/lib/widgets/book_details/bookmark_dialog.dart b/app/lib/widgets/book_details/bookmark_dialog.dart index 449cfb2..97f60b3 100644 --- a/app/lib/widgets/book_details/bookmark_dialog.dart +++ b/app/lib/widgets/book_details/bookmark_dialog.dart @@ -1,17 +1,22 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; +import 'package:uuid/uuid.dart'; /// Bottom sheet for creating or editing a bookmark manually. class BookmarkDialog extends StatefulWidget { final String bookId; final int? pageCount; final Bookmark? existingBookmark; + final FutureOr Function(Bookmark)? onSave; - const BookmarkDialog({super.key, required this.bookId, this.pageCount, this.existingBookmark}); + const BookmarkDialog({super.key, required this.bookId, this.pageCount, this.existingBookmark, this.onSave}); /// Shows the dialog and returns the created/updated bookmark, or null if cancelled. static Future show( @@ -19,6 +24,7 @@ class BookmarkDialog extends StatefulWidget { required String bookId, int? pageCount, Bookmark? existingBookmark, + FutureOr Function(Bookmark)? onSave, }) { return showModalBottomSheet( context: context, @@ -27,7 +33,8 @@ class BookmarkDialog extends StatefulWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), ), - builder: (context) => BookmarkDialog(bookId: bookId, pageCount: pageCount, existingBookmark: existingBookmark), + builder: (context) => + BookmarkDialog(bookId: bookId, pageCount: pageCount, existingBookmark: existingBookmark, onSave: onSave), ); } @@ -35,7 +42,7 @@ class BookmarkDialog extends StatefulWidget { State createState() => _BookmarkDialogState(); } -class _BookmarkDialogState extends State { +class _BookmarkDialogState extends State with PersistentSave { final _formKey = GlobalKey(); late final TextEditingController _pageController; late final TextEditingController _chapterController; @@ -61,7 +68,8 @@ class _BookmarkDialogState extends State { super.dispose(); } - void _save() { + Future _save() async { + if (isSaving) return; if (!(_formKey.currentState?.validate() ?? false)) return; final page = int.parse(_pageController.text); @@ -70,7 +78,7 @@ class _BookmarkDialogState extends State { final note = _noteController.text.trim(); final bookmark = Bookmark( - id: widget.existingBookmark?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), + id: widget.existingBookmark?.id ?? const Uuid().v4(), bookId: widget.bookId, position: position, pageNumber: page, @@ -79,7 +87,8 @@ class _BookmarkDialogState extends State { colorHex: _selectedColor, createdAt: widget.existingBookmark?.createdAt ?? DateTime.now(), ); - Navigator.of(context).pop(bookmark); + final saved = await persist(() => widget.onSave?.call(bookmark)); + if (saved && mounted) Navigator.of(context).pop(bookmark); } @override @@ -104,6 +113,8 @@ class _BookmarkDialogState extends State { title: _isEditing ? 'Edit bookmark' : 'New bookmark', onCancel: () => Navigator.of(context).pop(), onSave: _save, + canSave: !isSaving, + canCancel: !isSaving, ), const SizedBox(height: Spacing.md), const Divider(height: 1), diff --git a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart index 5b3fa04..e0469dc 100644 --- a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart +++ b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/widgets/shared/persistent_save.dart'; // ============================================================================= // BOOKMARK ACTION SHEET (action chooser) @@ -102,11 +105,16 @@ const _colorNames = { /// Bottom sheet for editing a bookmark's note. class BookmarkNoteSheet extends StatefulWidget { final Bookmark bookmark; + final FutureOr Function(String)? onSave; - const BookmarkNoteSheet({super.key, required this.bookmark}); + const BookmarkNoteSheet({super.key, required this.bookmark, this.onSave}); /// Show the note editing sheet. Returns the new note text, or null if cancelled. - static Future show(BuildContext context, {required Bookmark bookmark}) { + static Future show( + BuildContext context, { + required Bookmark bookmark, + FutureOr Function(String)? onSave, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, @@ -114,7 +122,7 @@ class BookmarkNoteSheet extends StatefulWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), ), - builder: (context) => BookmarkNoteSheet(bookmark: bookmark), + builder: (context) => BookmarkNoteSheet(bookmark: bookmark, onSave: onSave), ); } @@ -122,7 +130,7 @@ class BookmarkNoteSheet extends StatefulWidget { State createState() => _BookmarkNoteSheetState(); } -class _BookmarkNoteSheetState extends State { +class _BookmarkNoteSheetState extends State with PersistentSave { late TextEditingController _controller; @override @@ -155,9 +163,12 @@ class _BookmarkNoteSheetState extends State { BottomSheetHeader( title: 'Edit note', onCancel: () => Navigator.pop(context), - onSave: () { + canSave: !isSaving, + canCancel: !isSaving, + onSave: () async { final text = _controller.text.trim(); - Navigator.pop(context, text.isEmpty ? '' : text); + final saved = await persist(() => widget.onSave?.call(text)); + if (saved && context.mounted) Navigator.pop(context, text); }, ), const SizedBox(height: Spacing.md), @@ -190,22 +201,32 @@ class _BookmarkNoteSheetState extends State { // ============================================================================= /// Bottom sheet for selecting a bookmark color. -class BookmarkColorSheet extends StatelessWidget { +class BookmarkColorSheet extends StatefulWidget { final Bookmark bookmark; + final FutureOr Function(String)? onSave; - const BookmarkColorSheet({super.key, required this.bookmark}); + const BookmarkColorSheet({super.key, required this.bookmark, this.onSave}); /// Show the color picker sheet. Returns the selected color hex, or null. - static Future show(BuildContext context, {required Bookmark bookmark}) { + static Future show( + BuildContext context, { + required Bookmark bookmark, + FutureOr Function(String)? onSave, + }) { return showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), ), - builder: (context) => BookmarkColorSheet(bookmark: bookmark), + builder: (context) => BookmarkColorSheet(bookmark: bookmark, onSave: onSave), ); } + @override + State createState() => _BookmarkColorSheetState(); +} + +class _BookmarkColorSheetState extends State with PersistentSave { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -228,11 +249,16 @@ class BookmarkColorSheet extends StatelessWidget { spacing: Spacing.md, runSpacing: Spacing.md, children: Bookmark.availableColors.map((hex) { - final isSelected = hex == bookmark.colorHex; + final isSelected = hex == widget.bookmark.colorHex; final color = Color(int.parse('FF${hex.replaceFirst('#', '')}', radix: 16)); return GestureDetector( - onTap: () => Navigator.pop(context, hex), + onTap: isSaving + ? null + : () async { + final saved = await persist(() => widget.onSave?.call(hex)); + if (saved && context.mounted) Navigator.pop(context, hex); + }, child: Container( width: 48, height: 48, @@ -288,22 +314,51 @@ class BookmarkColorSheet extends StatelessWidget { /// Confirmation dialog for deleting a bookmark. class DeleteBookmarkDialog { /// Show the delete confirmation dialog. Returns true if confirmed. - static Future show(BuildContext context, {required Bookmark bookmark, required String bookTitle}) async { + static Future show( + BuildContext context, { + required Bookmark bookmark, + required String bookTitle, + FutureOr Function()? onDelete, + }) async { final result = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete bookmark'), - content: Text('Delete bookmark at ${bookmark.displayLocation} in "$bookTitle"?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), - FilledButton( - onPressed: () => Navigator.pop(context, true), - style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), - child: const Text('Delete'), - ), - ], - ), + builder: (context) => _DeleteBookmarkConfirmation(bookmark: bookmark, bookTitle: bookTitle, onDelete: onDelete), ); return result ?? false; } } + +class _DeleteBookmarkConfirmation extends StatefulWidget { + final Bookmark bookmark; + final String bookTitle; + final FutureOr Function()? onDelete; + + const _DeleteBookmarkConfirmation({required this.bookmark, required this.bookTitle, this.onDelete}); + + @override + State<_DeleteBookmarkConfirmation> createState() => _DeleteBookmarkConfirmationState(); +} + +class _DeleteBookmarkConfirmationState extends State<_DeleteBookmarkConfirmation> + with PersistentSave<_DeleteBookmarkConfirmation> { + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Delete bookmark'), + content: Text('Delete bookmark at ${widget.bookmark.displayLocation} in "${widget.bookTitle}"?'), + actions: [ + TextButton(onPressed: isSaving ? null : () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton( + onPressed: isSaving + ? null + : () async { + final saved = await persist(() => widget.onDelete?.call()); + if (saved && context.mounted) Navigator.pop(context, true); + }, + style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), + child: const Text('Delete'), + ), + ], + ); + } +} diff --git a/app/lib/widgets/library/book_grid.dart b/app/lib/widgets/library/book_grid.dart index 3e6589c..e728a1f 100644 --- a/app/lib/widgets/library/book_grid.dart +++ b/app/lib/widgets/library/book_grid.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:papyrus/utils/book_actions.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; @@ -148,7 +149,7 @@ class BookGrid extends StatelessWidget { return BookCard( book: book, isFavorite: isFavorite, - onToggleFavorite: job == null ? (current) => libraryProvider.toggleFavorite(book.id, current) : null, + onToggleFavorite: job == null ? (current) => toggleBookFavorite(context, book.id, current) : null, onTap: job != null ? onAcquisitionTap == null ? null diff --git a/app/test/pages/annotation_edit_test.dart b/app/test/pages/annotation_edit_test.dart new file mode 100644 index 0000000..451a40f --- /dev/null +++ b/app/test/pages/annotation_edit_test.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/pages/annotations_page.dart'; +import 'package:papyrus/widgets/book_details/annotation_card.dart'; +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 { + await tester.binding.setSurfaceSize(const Size(1200, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final store = DataStore(); + store.addBook(buildTestBook(id: 'book', isPhysical: true)); + await store.addAnnotation( + Annotation( + id: 'annotation', + bookId: 'book', + selectedText: 'Original passage', + color: HighlightColor.blue, + location: const BookLocation(pageNumber: 12, chapterTitle: 'Chapter', chapter: 2, percentage: 0.2), + note: 'Attached note', + createdAt: DateTime.utc(2026), + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: const MaterialApp(home: AnnotationsPage()), + ), + ); + await tester.pumpAndSettle(); + await tester.longPress(find.byType(AnnotationCard)); + await tester.pumpAndSettle(); + expect(find.text('Edit annotation'), findsOneWidget); + 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, '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), ''); + await tester.drag(find.byType(ListView).last, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(find.text('Highlight color'), findsOneWidget); + await tester.tap(find.byKey(const Key('annotation-color-green'))); + await tester.tap(find.text('Save')); + 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.chapter, 2); + expect(saved.location.percentage, 0.2); + expect(saved.note, isNull); + expect(saved.color, HighlightColor.green); + await tester.pumpWidget(const SizedBox()); + unawaited(store.disposeBookRepository()); + await tester.pump(); + store.dispose(); + }); +} diff --git a/app/test/powersync/bookmark_persistence_test.dart b/app/test/powersync/bookmark_persistence_test.dart new file mode 100644 index 0000000..5b408f1 --- /dev/null +++ b/app/test/powersync/bookmark_persistence_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/powersync/library_row_mapper.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; + +import '../helpers/test_helpers.dart'; +import 'powersync_service_test.dart' show OfflineConnector; + +void main() { + test('bookmark fields round-trip through SQLite values', () { + final bookmark = Bookmark( + id: 'mark', + bookId: 'book', + position: 0.25, + pageNumber: 25, + chapterTitle: 'Chapter', + note: 'Remember', + colorHex: '#2196F3', + createdAt: DateTime.utc(2026), + ); + expect(bookmarkRowMapper.fromRow(bookmarkRowMapper.toRow(bookmark)).toJson(), bookmark.toJson()); + }); + + for (final guest in [true, false]) { + test('bookmarks persist, merge, clear, react, and isolate in ${guest ? 'guest' : 'account'} storage', () async { + final directory = await Directory.systemTemp.createTemp('papyrus-bookmarks-'); + 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', isPhysical: true)); + final bookmark = Bookmark( + id: 'mark', + bookId: 'book', + position: 0.2, + pageNumber: 20, + chapterTitle: 'Chapter', + note: 'Remember', + createdAt: DateTime.utc(2026), + ); + await db.bookmarks.upsert(bookmark); + await db.watchLibrary().firstWhere((snapshot) => snapshot.bookmarks.isNotEmpty); + final store = DataStore(bookRepository: db); + await store.waitUntilLoaded(); + expect(store.bookmarks.single.note, 'Remember'); + await db.bookmarks.upsert(bookmark.copyWith(colorHex: '#2196F3'), previous: bookmark); + await db.bookmarks.upsert(bookmark.copyWith(note: null, chapterTitle: null), previous: bookmark); + await db.watchLibrary().firstWhere((snapshot) => snapshot.bookmarks.single.note == null); + expect((await db.bookmarks.getById('mark'))?.colorHex, '#2196F3'); + expect((await db.bookmarks.getById('mark'))?.chapterTitle, isNull); + await store.disposeBookRepository(); + await db.close(); + db = open(); + await activate(db); + expect((await db.bookmarks.getById('mark'))?.note, isNull); + expect((await db.bookmarks.getById('mark'))?.pageNumber, 20); + expect(db.syncState.hasPendingWrites, !guest); + final old = db.bookmarks; + await db.activateAuthenticated('other'); + expect(await db.bookmarks.getById('mark'), isNull); + await expectLater(old.upsert(bookmark), throwsStateError); + await activate(db); + await db.delete('book'); + expect(await db.bookmarks.getById('mark'), isNull); + await db.close(); + await directory.delete(recursive: true); + }); + } +} diff --git a/app/test/powersync/library_live_sync_test.dart b/app/test/powersync/library_live_sync_test.dart index 447b208..608e1b0 100644 --- a/app/test/powersync/library_live_sync_test.dart +++ b/app/test/powersync/library_live_sync_test.dart @@ -9,6 +9,7 @@ import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_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/models/shelf.dart'; import 'package:papyrus/models/tag.dart'; @@ -26,8 +27,24 @@ class _MemoryRefreshStorage implements RefreshTokenStorage { Future delete() async => token = null; } +class _DiagnosticClient extends http.BaseClient { + final http.Client _client = http.Client(); + + @override + Future send(http.BaseRequest request) async { + final response = await _client.send(request); + if (response.statusCode < 400 || !request.url.path.endsWith('/powersync-upload')) return response; + final body = await response.stream.toBytes(); + stderr.writeln('Sync upload failed (${response.statusCode}): ${utf8.decode(body)}'); + return http.StreamedResponse(Stream.value(body), response.statusCode, headers: response.headers, request: request); + } + + @override + void close() => _client.close(); +} + Future _eventually(Future Function() condition, String description) async { - final deadline = DateTime.now().add(const Duration(seconds: 30)); + final deadline = DateTime.now().add(const Duration(seconds: 90)); while (DateTime.now().isBefore(deadline)) { if (await condition()) return; await Future.delayed(const Duration(milliseconds: 100)); @@ -40,7 +57,7 @@ void main() { 'live devices converge after offline restart and isolate another account', () async { final directory = await Directory.systemTemp.createTemp('papyrus-live-library-'); - final client = http.Client(); + final client = _DiagnosticClient(); final config = PapyrusApiConfig(serverBaseUri: Uri.parse('http://localhost:8080')); AuthRepository auth() => AuthRepository( apiClient: AuthApiClient(config: config, httpClient: client), @@ -88,6 +105,7 @@ void main() { final tagId = const Uuid().v4(); final noteId = const Uuid().v4(); final annotationId = const Uuid().v4(); + final bookmarkId = const Uuid().v4(); final now = DateTime.now().toUtc(); try { await first.activateAuthenticated(owner.user.userId); @@ -107,6 +125,17 @@ void main() { await first.shelves.upsert(Shelf(id: shelfId, name: 'Shelf', createdAt: now, updatedAt: now)); await first.tags.upsert(Tag(id: tagId, name: 'Topic', colorHex: '#123456', createdAt: now)); await first.notes.upsert(Note(id: noteId, bookId: bookId, title: 'Note', content: 'Original', createdAt: now)); + await first.bookmarks.upsert( + Bookmark( + id: bookmarkId, + bookId: bookId, + position: 0.15, + pageNumber: 15, + chapterTitle: 'Chapter', + note: 'Original bookmark', + createdAt: now, + ), + ); await first.annotations.upsert( Annotation( id: annotationId, @@ -128,20 +157,34 @@ void main() { expect((await second.annotations.getById(annotationId))?.note, 'Attached'); expect((await second.getById(bookId))?.publicationDate, DateTime.utc(2020)); expect(await second.bookShelves.getById('$bookId:$shelfId'), isNotNull); + await _eventually( + () async => await second.bookmarks.getById(bookmarkId) != null, + 'bookmark reaches device two', + ); + expect((await second.bookmarks.getById(bookmarkId))?.pageNumber, 15); await second.setOnline(false); final baselineNote = (await second.notes.getById(noteId))!; final baselineBook = (await second.getById(bookId))!; + final baselineBookmark = (await second.bookmarks.getById(bookmarkId))!; + await second.bookmarks.upsert(baselineBookmark.copyWith(note: null), previous: baselineBookmark); await second.notes.upsert(baselineNote.copyWith(content: 'Offline edit'), previous: baselineNote); - await second.scopedBooks.update(baselineBook.copyWith(physicalLocation: 'Room B'), previous: baselineBook); + await second.scopedBooks.update( + baselineBook.copyWith(physicalLocation: 'Room B', isFavorite: true), + previous: baselineBook, + ); await second.close(); second = device('two', secondAuth, connect: false); await second.activateAuthenticated(owner.user.userId); expect((await second.notes.getById(noteId))?.content, 'Offline edit'); + expect((await second.bookmarks.getById(bookmarkId))?.note, isNull); + expect((await second.getById(bookId))?.isFavorite, isTrue); final currentNote = (await first.notes.getById(noteId))!; final currentBook = (await first.getById(bookId))!; await first.notes.upsert(currentNote.copyWith(title: 'Remote title'), previous: currentNote); await first.scopedBooks.update(currentBook.copyWith(lentTo: 'Reader'), previous: currentBook); + final onlineBookmark = (await first.bookmarks.getById(bookmarkId))!; + await first.bookmarks.upsert(onlineBookmark.copyWith(colorHex: '#2196F3'), previous: onlineBookmark); await _eventually(() async => !first.syncState.hasPendingWrites, 'online writes upload'); await second.setOnline(true); await _eventually( @@ -154,6 +197,16 @@ void main() { ); expect((await first.getById(bookId))?.physicalLocation, 'Room B'); await _eventually(() async => (await second.getById(bookId))?.lentTo == 'Reader', 'promoted book fields merge'); + await _eventually( + () async => (await first.getById(bookId))?.isFavorite == true, + 'favorite syncs after restart', + ); + await _eventually( + () async => + (await first.bookmarks.getById(bookmarkId))?.note == null && + (await second.bookmarks.getById(bookmarkId))?.colorHex == '#2196F3', + 'bookmark fields merge after restart', + ); await second.setOnline(false); final offlineNote = (await second.notes.getById(noteId))!; @@ -189,12 +242,15 @@ void main() { expect(await outsider.shelves.getById(shelfId), isNull); expect(await outsider.tags.getById(tagId), isNull); expect(await outsider.annotations.getById(annotationId), isNull); + expect(await outsider.bookmarks.getById(bookmarkId), isNull); expect(await outsider.bookShelves.getById('$bookId:$shelfId'), isNull); expect(await outsider.bookTags.getById('$bookId:$tagId'), isNull); await second.setOnline(false); final stale = (await second.notes.getById(noteId))!; await second.notes.upsert(stale.copyWith(content: 'Stale after deletion'), previous: stale); + final staleBookmark = (await second.bookmarks.getById(bookmarkId))!; + await second.bookmarks.upsert(staleBookmark.copyWith(note: 'Stale bookmark'), previous: staleBookmark); await first.delete(bookId); await _eventually(() async => !first.syncState.hasPendingWrites, 'book deletion uploads'); await second.setOnline(true); @@ -203,6 +259,10 @@ void main() { 'deletion wins against offline edit', ); expect(await second.annotations.getById(annotationId), isNull); + await _eventually( + () async => await second.bookmarks.getById(bookmarkId) == null, + 'bookmark deletion defeats stale edits', + ); expect(await second.bookShelves.getById('$bookId:$shelfId'), isNull); expect(await second.bookTags.getById('$bookId:$tagId'), isNull); await _eventually(() async => !second.syncState.hasPendingWrites, 'stale write queue drains'); @@ -229,6 +289,6 @@ void main() { } }, skip: Platform.environment['PAPYRUS_LIVE_SYNC'] != '1', - timeout: const Timeout(Duration(minutes: 3)), + timeout: const Timeout(Duration(minutes: 4)), ); } diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index 360b0ac..a3aa131 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -304,49 +304,49 @@ void main() { await provider.loadBook('book-1'); }); - test('addBookmark persists to DataStore', () { + test('addBookmark persists to DataStore', () async { final bookmark = buildTestBookmark(id: 'new-bm', bookId: 'book-1', position: 0.8); - provider.addBookmark(bookmark); + await provider.addBookmark(bookmark); expect(provider.bookmarks.length, 3); expect(dataStore.getBookmark('new-bm'), isNotNull); }); - test('updateBookmarkNote updates the bookmark note', () { - provider.updateBookmarkNote('bm-1', 'Updated note'); + test('updateBookmarkNote updates the bookmark note', () async { + await provider.updateBookmarkNote('bm-1', 'Updated note'); expect(dataStore.getBookmark('bm-1')!.note, 'Updated note'); }); - test('updateBookmarkNote clears note when null', () { + test('updateBookmarkNote clears note when null', () async { // First set a note - provider.updateBookmarkNote('bm-1', 'A note'); + await provider.updateBookmarkNote('bm-1', 'A note'); expect(dataStore.getBookmark('bm-1')!.note, 'A note'); // Then clear it - provider.updateBookmarkNote('bm-1', null); + await provider.updateBookmarkNote('bm-1', null); expect(dataStore.getBookmark('bm-1')!.note, isNull); }); - test('updateBookmarkColor updates the bookmark color', () { - provider.updateBookmarkColor('bm-1', '#FF0000'); + test('updateBookmarkColor updates the bookmark color', () async { + await provider.updateBookmarkColor('bm-1', '#FF0000'); expect(dataStore.getBookmark('bm-1')!.colorHex, '#FF0000'); }); - test('deleteBookmark removes from DataStore', () { - provider.deleteBookmark('bm-1'); + test('deleteBookmark removes from DataStore', () async { + await provider.deleteBookmark('bm-1'); expect(provider.bookmarks.length, 1); expect(dataStore.getBookmark('bm-1'), isNull); }); - test('updateBookmarkNote does nothing for nonexistent bookmark', () { + test('updateBookmarkNote does nothing for nonexistent bookmark', () async { var notified = false; provider.addListener(() => notified = true); - provider.updateBookmarkNote('nonexistent', 'A note'); + await provider.updateBookmarkNote('nonexistent', 'A note'); expect(notified, false); }); diff --git a/app/test/providers/bookmark_persistence_test.dart b/app/test/providers/bookmark_persistence_test.dart new file mode 100644 index 0000000..c21d45f --- /dev/null +++ b/app/test/providers/bookmark_persistence_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/providers/book_details_provider.dart'; +import 'package:papyrus/providers/bookmarks_provider.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; + +import '../helpers/test_helpers.dart'; +import '../powersync/powersync_service_test.dart' show OfflineConnector; + +void main() { + late Directory directory; + late PapyrusPowerSyncService service; + late DataStore store; + late BookmarksProvider bookmarks; + late BookDetailsProvider details; + final original = Bookmark( + id: 'bookmark', + bookId: 'book', + position: 0.25, + pageNumber: 30, + chapterTitle: 'Chapter', + note: 'Original note', + createdAt: DateTime.utc(2026), + ); + + setUp(() async { + directory = await Directory.systemTemp.createTemp('papyrus-bookmark-edit-'); + service = PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + await service.activateGuest(); + await service.upsert(buildTestBook(id: 'book')); + await service.bookmarks.upsert(original); + store = DataStore(bookRepository: service); + bookmarks = BookmarksProvider()..attach(store); + details = BookDetailsProvider()..setDataStore(store); + }); + + tearDown(() async { + bookmarks.dispose(); + details.dispose(); + await store.disposeBookRepository(); + await service.close(); + await directory.delete(recursive: true); + }); + + test('note edit preserves remotely changed color and location', () async { + final repository = store.libraryRepository!.bookmarks; + await service.bookmarks.upsert(original.copyWith(colorHex: '#2196F3', pageNumber: 40)); + await bookmarks.updateBookmarkNote(original.id, null, previous: original, repository: repository); + final saved = await service.bookmarks.getById(original.id); + expect(saved!.note, isNull); + expect(saved.colorHex, '#2196F3'); + expect(saved.pageNumber, 40); + }); + + test('details color edit preserves a remotely changed note', () async { + final repository = store.libraryRepository!.bookmarks; + await service.bookmarks.upsert(original.copyWith(note: 'Remote note')); + await details.updateBookmarkColor(original.id, '#2196F3', previous: original, repository: repository); + final saved = await service.bookmarks.getById(original.id); + expect(saved!.note, 'Remote note'); + expect(saved.colorHex, '#2196F3'); + }); + + test('bookmark edits, creation and deletion cannot cross a profile switch', () async { + final repository = store.libraryRepository!.bookmarks; + await service.activateAuthenticated('other-account'); + await service.upsert(buildTestBook(id: 'book')); + await service.bookmarks.upsert(original.copyWith(note: 'Other account note')); + await expectLater( + bookmarks.updateBookmarkNote(original.id, 'Stale note', previous: original, repository: repository), + throwsStateError, + ); + await expectLater( + details.updateBookmarkColor(original.id, '#2196F3', previous: original, repository: repository), + throwsStateError, + ); + await expectLater( + details.addBookmark(original.copyWith(id: 'new-bookmark'), repository: repository), + throwsStateError, + ); + await expectLater(bookmarks.deleteBookmark(original.id, repository: repository), throwsStateError); + expect((await service.bookmarks.getById(original.id))!.note, 'Other account note'); + expect(await service.bookmarks.getById('new-bookmark'), isNull); + }); +} diff --git a/app/test/providers/library_favorite_persistence_test.dart b/app/test/providers/library_favorite_persistence_test.dart new file mode 100644 index 0000000..bc30655 --- /dev/null +++ b/app/test/providers/library_favorite_persistence_test.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; + +import '../helpers/test_helpers.dart'; +import '../powersync/powersync_service_test.dart' show OfflineConnector; + +void main() { + test('favorites persist, follow remote changes, and remain scoped', () async { + final directory = await Directory.systemTemp.createTemp('papyrus-favorites-'); + PapyrusPowerSyncService open() => PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + var service = open(); + await service.activateGuest(); + await service.upsert(buildTestBook(id: 'book')); + await service.watchLibrary().firstWhere((snapshot) => snapshot.books.isNotEmpty); + final store = DataStore(bookRepository: service); + await store.waitUntilLoaded(); + final provider = LibraryProvider(dataStore: store); + final shelfProvider = LibraryProvider(favoriteDelegate: provider); + await shelfProvider.toggleFavorite('book', false); + expect((await service.getById('book'))?.isFavorite, isTrue); + await service.watchLibrary().firstWhere((snapshot) => snapshot.books.single.isFavorite); + final current = (await service.getById('book'))!; + await service.scopedBooks.update(current.copyWith(isFavorite: false), previous: current); + await service.watchLibrary().firstWhere((snapshot) => snapshot.books.single.isFavorite == false); + await Future.delayed(Duration.zero); + expect(provider.isBookFavorite('book', true), isFalse); + await provider.toggleFavorite('book', false); + await service.watchLibrary().firstWhere((snapshot) => snapshot.books.single.isFavorite); + await service.activateAuthenticated('other'); + await service.upsert(buildTestBook(id: 'book')); + await service.watchLibrary().firstWhere((snapshot) => snapshot.books.isNotEmpty); + await Future.delayed(Duration.zero); + expect(provider.isBookFavorite('book', false), isFalse); + shelfProvider.dispose(); + provider.dispose(); + await store.disposeBookRepository(); + await service.close(); + service = open(); + await service.activateGuest(); + expect((await service.getById('book'))?.isFavorite, isTrue); + await service.close(); + await directory.delete(recursive: true); + }); +} diff --git a/app/test/widgets/book_details/bookmark_dialog_test.dart b/app/test/widgets/book_details/bookmark_dialog_test.dart new file mode 100644 index 0000000..d622e0d --- /dev/null +++ b/app/test/widgets/book_details/bookmark_dialog_test.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/widgets/book_details/bookmark_dialog.dart'; +import 'package:papyrus/widgets/bookmarks/bookmark_action_sheet.dart'; + +void main() { + final bookmark = Bookmark( + id: 'bookmark', + bookId: 'physical-book', + position: 0.25, + pageNumber: 30, + note: 'Existing note', + createdAt: DateTime.utc(2026), + ); + + Finder colorChoices() => find.byWidgetPredicate( + (widget) => + widget is GestureDetector && + widget.child is Container && + (widget.child as Container).constraints?.minWidth == 48, + ); + + Future open(WidgetTester tester, void Function(BuildContext) show) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton(onPressed: () => show(context), child: const Text('Open')), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + 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'}', ( + tester, + ) async { + final write = Completer(); + var calls = 0; + Future save() { + calls++; + return write.future; + } + + await open(tester, (context) { + switch (kind) { + case 'create': + BookmarkDialog.show(context, bookId: 'physical-book', pageCount: 120, onSave: (_) => save()); + case 'note': + BookmarkNoteSheet.show(context, bookmark: bookmark, onSave: (_) => save()); + case 'color': + BookmarkColorSheet.show(context, bookmark: bookmark, onSave: (_) => save()); + case 'delete': + DeleteBookmarkDialog.show(context, bookmark: bookmark, bookTitle: 'Physical Book', onDelete: save); + } + }); + if (kind == 'create') await tester.enterText(find.byType(TextFormField).first, '30'); + final action = kind == 'color' + ? colorChoices().first + : find.widgetWithText(FilledButton, kind == 'delete' ? 'Delete' : 'Save'); + await tester.tap(action); + await tester.pump(); + expect(calls, 1); + expect(action, findsOneWidget); + if (kind == 'color') { + expect(tester.widget(action).onTap, isNull); + } else { + expect(tester.widget(action).onPressed, isNull); + } + + if (fails) { + write.completeError(StateError('Disk full')); + } else { + write.complete(); + } + await tester.pumpAndSettle(); + if (kind == 'color' && !fails) { + expect(colorChoices(), findsNothing); + } else { + expect(action, fails ? findsOneWidget : findsNothing); + } + if (fails) { + expect(find.text('Could not save changes. Please try again.'), findsOneWidget); + if (kind == 'color') { + expect(tester.widget(action).onTap, isNotNull); + } else { + expect(tester.widget(action).onPressed, isNotNull); + } + } + }); + } + } + + testWidgets('manual physical bookmark saves page, position, chapter, note and selected color with a UUID', ( + tester, + ) async { + Bookmark? saved; + await open( + tester, + (context) => BookmarkDialog.show( + context, + bookId: 'physical-book', + pageCount: 120, + onSave: (bookmark) { + saved = bookmark; + }, + ), + ); + final fields = find.byType(TextFormField); + await tester.enterText(fields.at(0), '30'); + await tester.enterText(fields.at(1), ' First chapter '); + await tester.enterText(fields.at(2), ' Remember this '); + final blue = find.byWidgetPredicate( + (widget) => + widget is GestureDetector && + widget.child is Container && + (widget.child as Container).decoration is BoxDecoration && + ((widget.child as Container).decoration as BoxDecoration).color == const Color(0xFF2196F3), + ); + await tester.tap(blue); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!.id, matches(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'))); + expect(saved!.bookId, 'physical-book'); + expect(saved!.pageNumber, 30); + expect(saved!.position, 0.25); + expect(saved!.chapterTitle, 'First chapter'); + expect(saved!.note, 'Remember this'); + expect(saved!.colorHex, '#2196F3'); + expect(saved!.createdAt.difference(DateTime.now()).inSeconds.abs(), lessThan(5)); + }); + + testWidgets('physical bookmark without a total page count retains its entered page', (tester) async { + Bookmark? saved; + await open( + tester, + (context) => BookmarkDialog.show( + context, + bookId: 'physical-book', + onSave: (bookmark) { + saved = bookmark; + }, + ), + ); + await tester.enterText(find.byType(TextFormField).first, '47'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + expect(saved!.pageNumber, 47); + expect(saved!.position, 0); + expect(saved!.chapterTitle, isNull); + expect(saved!.note, isNull); + }); +} From 773951d197286d5b71aea0cd1b609c6b1b6618d2 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 6 Sep 2026 01:58:36 +0300 Subject: [PATCH 3/3] Remove outdated specs. --- .../2026-07-11-local-first-cover-lifecycle.md | 623 -------------- .../2026-07-11-media-storage-hardening.md | 782 ----------------- ...26-07-11-stable-local-cover-image-cache.md | 373 -------- .../2026-07-12-book-edit-layout-alignment.md | 46 - .../2026-07-13-add-book-bottom-sheets.md | 174 ---- .../2026-07-13-book-edit-responsive-pane.md | 239 ------ ...2026-07-14-add-book-backdrop-continuity.md | 133 --- .../2026-07-14-import-action-button-shapes.md | 155 ---- .../2026-07-14-import-add-loading-state.md | 175 ---- .../2026-07-24-acquisition-bottom-sheets.md | 664 --------------- .../plans/2026-07-27-reader-integration.md | 62 -- ...2026-08-01-advanced-filter-sheet-sizing.md | 105 --- .../2026-08-01-book-import-workflow-rework.md | 805 ------------------ .../2026-08-01-reusable-shelf-books-page.md | 754 ---------------- .../plans/2026-08-01-shelf-page-heading.md | 157 ---- .../plans/2026-08-01-shelves-page-controls.md | 270 ------ .../plans/2026-08-23-book-import-drop-zone.md | 95 --- .../2026-08-23-book-import-sheet-refactor.md | 276 ------ .../plans/2026-08-23-book-storage-status.md | 353 -------- .../2026-07-11-cover-image-cache-design.md | 95 --- ...26-07-11-media-storage-hardening-design.md | 163 ---- ...026-07-13-add-book-bottom-sheets-design.md | 40 - ...-07-13-book-edit-responsive-pane-design.md | 55 -- ...-14-add-book-backdrop-continuity-design.md | 35 - ...7-14-import-action-button-shapes-design.md | 23 - ...6-07-14-import-add-loading-state-design.md | 37 - ...-07-24-acquisition-bottom-sheets-design.md | 108 --- .../2026-07-27-reader-integration-design.md | 25 - ...-library-filters-visual-redesign-design.md | 97 --- ...-01-advanced-filter-sheet-sizing-design.md | 45 - ...8-01-book-import-workflow-rework-design.md | 127 --- ...-08-01-reusable-shelf-books-page-design.md | 164 ---- .../2026-08-01-shelf-page-heading-design.md | 69 -- ...2026-08-01-shelves-page-controls-design.md | 107 --- ...2026-08-23-book-import-drop-zone-design.md | 60 -- ...08-23-book-import-sheet-refactor-design.md | 63 -- .../2026-08-23-book-storage-status-design.md | 203 ----- 37 files changed, 7757 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md delete mode 100644 docs/superpowers/plans/2026-07-11-media-storage-hardening.md delete mode 100644 docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md delete mode 100644 docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md delete mode 100644 docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md delete mode 100644 docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md delete mode 100644 docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md delete mode 100644 docs/superpowers/plans/2026-07-14-import-action-button-shapes.md delete mode 100644 docs/superpowers/plans/2026-07-14-import-add-loading-state.md delete mode 100644 docs/superpowers/plans/2026-07-24-acquisition-bottom-sheets.md delete mode 100644 docs/superpowers/plans/2026-07-27-reader-integration.md delete mode 100644 docs/superpowers/plans/2026-08-01-advanced-filter-sheet-sizing.md delete mode 100644 docs/superpowers/plans/2026-08-01-book-import-workflow-rework.md delete mode 100644 docs/superpowers/plans/2026-08-01-reusable-shelf-books-page.md delete mode 100644 docs/superpowers/plans/2026-08-01-shelf-page-heading.md delete mode 100644 docs/superpowers/plans/2026-08-01-shelves-page-controls.md delete mode 100644 docs/superpowers/plans/2026-08-23-book-import-drop-zone.md delete mode 100644 docs/superpowers/plans/2026-08-23-book-import-sheet-refactor.md delete mode 100644 docs/superpowers/plans/2026-08-23-book-storage-status.md delete mode 100644 docs/superpowers/specs/2026-07-11-cover-image-cache-design.md delete mode 100644 docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md delete mode 100644 docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md delete mode 100644 docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md delete mode 100644 docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md delete mode 100644 docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-acquisition-bottom-sheets-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-reader-integration-design.md delete mode 100644 docs/superpowers/specs/2026-07-30-advanced-library-filters-visual-redesign-design.md delete mode 100644 docs/superpowers/specs/2026-08-01-advanced-filter-sheet-sizing-design.md delete mode 100644 docs/superpowers/specs/2026-08-01-book-import-workflow-rework-design.md delete mode 100644 docs/superpowers/specs/2026-08-01-reusable-shelf-books-page-design.md delete mode 100644 docs/superpowers/specs/2026-08-01-shelf-page-heading-design.md delete mode 100644 docs/superpowers/specs/2026-08-01-shelves-page-controls-design.md delete mode 100644 docs/superpowers/specs/2026-08-23-book-import-drop-zone-design.md delete mode 100644 docs/superpowers/specs/2026-08-23-book-import-sheet-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-08-23-book-storage-status-design.md diff --git a/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md b/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md deleted file mode 100644 index a45a6c3..0000000 --- a/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md +++ /dev/null @@ -1,623 +0,0 @@ -# Local-First Cover Lifecycle Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Keep guest and account cover bytes in filesystem/OPFS, synchronize only `cover_media_id`, and make account covers survive offline import and appear on other devices through lazy authenticated downloads. - -**Architecture:** Extend the existing scoped cover store with explicit cached, pending, and guest-book buckets. Account imports write `pending/.bin`, synchronize metadata without an inline cover, and enqueue a metadata-only cover task; successful upload promotes those bytes to `cached/.bin`. Guest imports write `local/guest/books/.bin`, while other account devices receive `cover_media_id` and reuse the existing lazy cache downloader. - -**Tech Stack:** Flutter/Dart, `SharedPreferences`, native filesystem through `path_provider`, web OPFS through `book_worker.js`, PowerSync, Flutter widget/unit tests. - ---- - -## File structure - -- Create `app/lib/media/cover_storage_bucket.dart`: safe bucket names shared by native and web cover storage. -- Create `app/lib/services/book_import_commit_service.dart`: one testable import-commit boundary that persists a cover before saving metadata and enqueueing account media. -- Create `app/test/services/book_import_commit_service_test.dart`: verifies guest/account behavior without widget timing. -- Modify `app/lib/media/media_storage_scope.dart`: add the single local guest cover namespace. -- Modify `app/lib/services/book_import_service_stub.dart`: native cached, pending, and guest cover operations. -- Modify `app/lib/services/book_import_service.dart`: matching web worker requests and pending-to-cache promotion. -- Modify `app/web/book_worker.js`: validate a cover bucket and store files beneath that bucket. -- Modify `app/lib/media/media_upload_queue.dart`: persist cover task metadata only and read pending bytes at processing time. -- Modify `app/lib/widgets/add_book/import_book_sheet.dart`: delegate committing to the new service; stop generating new data URIs. -- Modify `app/lib/widgets/book/private_book_cover.dart`: load a book-ID fallback when no public URL or media ID exists. -- Modify cover call sites and `CoverPreview`: propagate book IDs to the shared cover renderer. -- Modify `app/lib/main.dart`: promote uploaded pending cover bytes to the media-ID cache. -- Modify book deletion cleanup: delete pending/guest files as well as media-ID cache files. - -### Task 1: Add explicit filesystem cover buckets - -**Files:** - -- Create: `app/lib/media/cover_storage_bucket.dart` -- Modify: `app/lib/media/media_storage_scope.dart` -- Modify: `app/lib/services/book_import_service_stub.dart` -- Modify: `app/lib/services/book_import_service.dart` -- Modify: `app/web/book_worker.js` -- Test: `app/test/services/book_cover_storage_test.dart` -- Test: `app/test/media/media_storage_scope_test.dart` - -- [ ] **Step 1: Write failing native storage and guest-scope tests** - -Add tests that exercise the wished-for API: - -```dart -test('pending and cached covers use separate files', () async { - final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([1])); - await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([2])); - - expect(await service.getPendingCoverFile(scope, 'book-1'), [1]); - expect(await service.getCoverFile(scope, 'asset-1'), [2]); -}); - -test('guest cover files use the local guest namespace', () async { - await service.storeGuestCoverFile('book-1', Uint8List.fromList([3])); - expect(await service.getGuestCoverFile('book-1'), [3]); - expect(MediaStorageScope.localGuest.persistenceKey, 'local--guest'); -}); - -test('promotion stores media cache bytes and removes pending file', () async { - final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([4])); - await service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1'); - - expect(await service.getPendingCoverFile(scope, 'book-1'), isNull); - expect(await service.getCoverFile(scope, 'asset-1'), [4]); -}); -``` - -Extend the worker source assertion to require `bucket` validation and the buckets `cached`, `pending`, and `books`. - -- [ ] **Step 2: Run tests and verify RED** - -Run: - -```bash -cd app -flutter test test/services/book_cover_storage_test.dart test/media/media_storage_scope_test.dart --reporter expanded -``` - -Expected: FAIL because `localGuest`, pending/guest methods, promotion, and worker bucket handling do not exist. - -- [ ] **Step 3: Implement bucketed native and web storage** - -Create: - -```dart -enum CoverStorageBucket { - cached('cached'), - pending('pending'), - guestBooks('books'); - - const CoverStorageBucket(this.pathComponent); - final String pathComponent; -} -``` - -Add: - -```dart -static const localGuest = MediaStorageScope(profileKey: 'local', userId: 'guest'); -``` - -Route the existing `getCoverFile`, `storeCoverFile`, and `deleteCoverFile` through `CoverStorageBucket.cached`. Add pending and guest wrappers. Native `_coverFile` must build: - -```dart -File(p.join(directory.path, bucket.pathComponent, '$id.bin')) -``` - -and create the bucket directory recursively. Promotion must read pending bytes, atomically store the cached file, and delete pending only after the cached write succeeds. - -On web, add `bucket` to `_sendCoverRequest`; the worker must reject values outside: - -```javascript -const COVER_BUCKETS = new Set(['cached', 'pending', 'books']); -``` - -and resolve `media-covers///.bin`. Preserve the current copied `ArrayBuffer` transfer so caller bytes are not detached. - -- [ ] **Step 4: Run focused tests and worker syntax check** - -Run: - -```bash -cd app -flutter test test/services/book_cover_storage_test.dart test/media/media_storage_scope_test.dart --reporter expanded -node --check web/book_worker.js -``` - -Expected: all focused tests pass and Node exits 0. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/media/cover_storage_bucket.dart app/lib/media/media_storage_scope.dart app/lib/services/book_import_service_stub.dart app/lib/services/book_import_service.dart app/web/book_worker.js app/test/services/book_cover_storage_test.dart app/test/media/media_storage_scope_test.dart -git commit -m "feat: add pending and guest cover storage" -``` - -### Task 2: Make queued cover uploads reference pending files - -**Files:** - -- Modify: `app/lib/media/media_upload_queue.dart` -- Modify: `app/test/media/media_upload_queue_test.dart` - -- [ ] **Step 1: Write failing queue persistence and restore tests** - -Add: - -```dart -test('cover task persists metadata without cover bytes', () async { - final prefs = await SharedPreferences.getInstance(); - final queue = await _activeQueue(prefs); - final book = _book(filePath: 'book-1'); - - await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); - - final stored = prefs.getString('media_upload_queue:official--user-1')!; - expect(stored, isNot(contains('cover_base64'))); - expect(stored, isNot(contains(base64Encode([1, 2, 3])))); -}); - -test('restored cover task reads pending filesystem bytes', () async { - final prefs = await SharedPreferences.getInstance(); - final first = await _activeQueue(prefs); - final book = _book(filePath: 'book-1'); - await first.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); - - final restored = await _activeQueue(prefs); - Uint8List? uploaded; - await restored.processPending( - dataStore: _dataStoreContaining(book), - readBookFile: (_) async => null, - readPendingCover: (_, bookId) async => bookId == book.id ? Uint8List.fromList([1, 2, 3]) : null, - uploadMedia: (payload) async { - uploaded = payload.bytes; - return _asset(assetId: 'cover-1', bookId: book.id, kind: MediaKind.coverImage); - }, - ); - expect(uploaded, [1, 2, 3]); -}); -``` - -Keep a compatibility test proving an already persisted `cover_base64` task can drain once, but ensure new tasks never serialize that field. - -- [ ] **Step 2: Run the queue tests and verify RED** - -Run: - -```bash -cd app -flutter test test/media/media_upload_queue_test.dart --reporter expanded -``` - -Expected: FAIL because `enqueueCover` still requires bytes and `processPending` has no `readPendingCover` dependency. - -- [ ] **Step 3: Implement metadata-only cover tasks** - -Add: - -```dart -typedef PendingCoverReader = Future Function(MediaStorageScope scope, String bookId); -``` - -Change new cover enqueueing to omit `coverBase64`. Pass `readPendingCover` into processing and resolve bytes as: - -```dart -if (task.kind == MediaKind.coverImage) { - final legacy = task.coverBase64; - return legacy == null ? readPendingCover(scope, task.bookId) : base64Decode(legacy); -} -``` - -Build `toJson()` with conditional entries so `cover_base64` is absent for new tasks. Retain nullable legacy parsing only until existing development queues have drained. Keep the current `_processAgain` behavior and its regression test: a PowerSync callback arriving during a failed upload must trigger a fresh pass. - -- [ ] **Step 4: Run queue tests and verify GREEN** - -Run: - -```bash -cd app -flutter test test/media/media_upload_queue_test.dart --reporter expanded -``` - -Expected: all queue tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/media/media_upload_queue.dart app/test/media/media_upload_queue_test.dart -git commit -m "fix: queue cover file references instead of bytes" -``` - -### Task 3: Persist imported covers before saving book metadata - -**Files:** - -- Create: `app/lib/services/book_import_commit_service.dart` -- Create: `app/test/services/book_import_commit_service_test.dart` -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` - -- [ ] **Step 1: Write failing guest and account commit tests** - -Define tests around a small commit service with injected storage callbacks. The account test must assert call order and metadata: - -```dart -test('account import stores pending cover and saves no inline cover metadata', () async { - final calls = []; - final result = _resultWithCover([1, 2, 3]); - final service = BookImportCommitService( - storePendingCover: (_, bookId, bytes) async => calls.add('store:$bookId:${bytes.length}'), - storeGuestCover: (_, __) async => fail('guest storage must not be used'), - addBook: (book) async { - calls.add('book:${book.id}'); - expect(book.coverUrl, isNull); - }, - enqueueBookFile: (_) async => calls.add('file-task'), - enqueueCover: (_) async => calls.add('cover-task'), - ); - - await service.commit(result: result, filename: 'book.epub', accountScope: _scope); - expect(calls, ['store:${result.bookId}:3', 'book:${result.bookId}', 'file-task', 'cover-task']); -}); - -test('guest import stores permanent local cover without upload tasks', () async { - final calls = []; - final result = _resultWithCover([4, 5]); - final service = BookImportCommitService( - storePendingCover: (_, __, ___) async => fail('pending storage must not be used'), - storeGuestCover: (bookId, bytes) async => calls.add('guest:$bookId:${bytes.length}'), - addBook: (book) async { - calls.add('book:${book.id}'); - expect(book.coverUrl, isNull); - }, - enqueueBookFile: (_) async => fail('guest file must not be enqueued'), - enqueueCover: (_) async => fail('guest cover must not be enqueued'), - ); - - await service.commit(result: result, filename: 'book.epub', accountScope: null); - expect(calls, ['guest:${result.bookId}:2', 'book:${result.bookId}']); -}); -``` - -- [ ] **Step 2: Run the new test and verify RED** - -Run: - -```bash -cd app -flutter test test/services/book_import_commit_service_test.dart --reporter expanded -``` - -Expected: compilation fails because `BookImportCommitService` does not exist. - -- [ ] **Step 3: Implement the import commit boundary** - -The service must: - -```dart -final cover = result.coverImage; -if (cover != null) { - if (accountScope == null) { - await storeGuestCover(result.bookId, cover); - } else { - await storePendingCover(accountScope, result.bookId, cover); - } -} - -final book = Book( - id: result.bookId, - title: result.title, - subtitle: result.subtitle, - author: result.author, - coAuthors: result.coAuthors, - publisher: result.publisher, - description: result.description, - language: result.language, - isbn: result.isbn, - pageCount: result.pageCount, - coverUrl: null, - filePath: localFilePath, - fileFormat: BookFormat.values.where((value) => value.name == result.fileExtension).firstOrNull, - fileSize: result.fileSize, - fileHash: result.fileHash, - addedAt: now, -); -await addBook(book); -``` - -Only account mode enqueues book-file and cover tasks. The cover task contains no bytes. Change `_addToLibrary` to `Future`, determine `accountScope` from authenticated mode, and delegate. Remove `bytesToDataUri` from this import path. Guard post-await navigation with `if (!mounted) return`. - -- [ ] **Step 4: Run import and queue tests** - -Run: - -```bash -cd app -flutter test test/services/book_import_commit_service_test.dart test/media/media_upload_queue_test.dart --reporter expanded -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/services/book_import_commit_service.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/services/book_import_commit_service_test.dart -git commit -m "feat: persist imported covers outside metadata" -``` - -### Task 4: Render pending and guest covers by book ID - -**Files:** - -- Modify: `app/lib/widgets/book/private_book_cover.dart` -- Modify: `app/lib/widgets/book_details/book_cover_image.dart` -- Modify: `app/lib/data/data_store.dart` -- Modify: `app/lib/models/shelf.dart` -- Modify: all ten production `CoverImage`/`CoverImagePreview` callers found by `rg -n "CoverImage\\(|CoverImagePreview\\(" app/lib` -- Test: `app/test/widgets/book/private_book_cover_test.dart` - -- [ ] **Step 1: Write failing fallback-order widget tests** - -Add injected local loading to keep tests independent of providers: - -```dart -testWidgets('book without media id renders pending local cover', (tester) async { - await tester.pumpWidget(MaterialApp( - home: CoverImage( - bookId: 'book-1', - loadLocalBookCover: (_) async => Uint8List.fromList(pngBytes), - placeholder: const SizedBox(key: Key('placeholder')), - ), - )); - await tester.pumpAndSettle(); - expect(find.byType(Image), findsOneWidget); -}); - -testWidgets('media id takes priority over pending local cover', (tester) async { - var localLoads = 0; - var mediaLoads = 0; - await tester.pumpWidget(MaterialApp( - home: CoverImage( - bookId: 'book-1', - mediaId: 'asset-1', - loadPrivateCover: (_) async { - mediaLoads++; - return Uint8List.fromList(pngBytes); - }, - loadLocalBookCover: (_) async { - localLoads++; - return Uint8List.fromList(pngBytes); - }, - placeholder: const SizedBox(), - ), - )); - await tester.pumpAndSettle(); - expect(mediaLoads, 1); - expect(localLoads, 0); -}); -``` - -Retain the legacy inline-data test solely for old rows; new imports are covered by Task 3 and must not create data URIs. - -- [ ] **Step 2: Run widget tests and verify RED** - -Run: - -```bash -cd app -flutter test test/widgets/book/private_book_cover_test.dart --reporter expanded -``` - -Expected: FAIL because `bookId` and `loadLocalBookCover` are not accepted. - -- [ ] **Step 3: Implement local fallback loading and propagate book IDs** - -Use this order in `CoverImage`: public/legacy URL, `mediaId` cache/download, then local book cover. Provider loading chooses pending account storage when signed into an account library and guest storage otherwise: - -```dart -if (mediaId == null && bookId != null) { - _coverFuture = isAccountLibrary - ? importService.getPendingCoverFile(accountScope, bookId) - : importService.getGuestCoverFile(bookId); -} -``` - -Add `bookId` to `CoverImagePreview` and `CoverPreview`. Pass `book.id` from book-backed call sites and `cover.bookId` from shelf mosaics/group previews. Include book ID in `_loadKey` so recycled widgets cannot show another book's pending cover. - -- [ ] **Step 4: Run widget and representative surface tests** - -Run: - -```bash -cd app -flutter test test/widgets/book/private_book_cover_test.dart test/widgets/library/book_card_test.dart test/widgets/library/book_list_item_test.dart --reporter expanded -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/widgets app/lib/data/data_store.dart app/lib/models app/test/widgets/book/private_book_cover_test.dart -git commit -m "feat: render local covers by book id" -``` - -### Task 5: Promote source-device covers after upload - -**Files:** - -- Create: `app/lib/media/cover_upload_persistence.dart` -- Create: `app/test/media/cover_upload_persistence_test.dart` -- Modify: `app/lib/main.dart` -- Modify: `app/test/media/media_cache_service_test.dart` -- Modify: `app/test/media/media_profile_switch_contract_test.dart` - -- [ ] **Step 1: Write a failing promotion orchestration test** - -Create `uploadAndPersistCover` in `cover_upload_persistence.dart` so the behavior is testable without constructing `_PapyrusState`: - -```dart -test('successful cover upload promotes pending bytes into media cache', () async { - final calls = []; - final asset = await uploadAndPersistCover( - scope: _scope, - payload: _coverPayload(bookId: 'book-1', bytes: [1, 2, 3]), - uploadMedia: (_) async => _coverAsset(assetId: 'asset-1', bookId: 'book-1'), - storeCachedCover: (_, mediaId, bytes) async => calls.add('cache:$mediaId:${bytes.length}'), - deletePendingCover: (_, bookId) async => calls.add('delete:$bookId'), - ); - expect(asset.assetId, 'asset-1'); - expect(calls, ['cache:asset-1:3', 'delete:book-1']); -}); -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: - -```bash -cd app -flutter test test/media/cover_upload_persistence_test.dart --reporter expanded -``` - -Expected: compilation fails because `cover_upload_persistence.dart` and `uploadAndPersistCover` do not exist. - -- [ ] **Step 3: Implement best-effort promotion after server success** - -Wrap the repository upload used by `_processMediaUploads`. For `MediaKind.coverImage`, atomically store `payload.bytes` under `asset.assetId`, then delete `pending/`. A local cache failure must not convert an already successful server upload into a duplicate retry; report it through `FlutterError.reportError` and still return the asset. - -The subsequent `_applyUploadedAsset` update sets `coverMediaId`. The source device then reads the promoted cache, while remote devices use the existing `MediaCacheService.ensureCoverCached` download path. - -- [ ] **Step 4: Run promotion, profile, queue, and cache tests** - -```bash -cd app -flutter test test/media --reporter expanded -``` - -Expected: all media tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/main.dart app/lib/media/cover_upload_persistence.dart app/test/media/cover_upload_persistence_test.dart app/test/media/media_cache_service_test.dart app/test/media/media_profile_switch_contract_test.dart -git commit -m "feat: promote uploaded covers into local cache" -``` - -### Task 6: Delete every local cover representation - -**Files:** - -- Modify: `app/lib/services/book_delete_cleanup_service.dart` -- Modify: `app/lib/pages/book_details_page.dart` -- Modify: `app/test/services/book_delete_cleanup_service_test.dart` - -- [ ] **Step 1: Write a failing cleanup test** - -```dart -test('delete removes pending guest and cached cover files best effort', () async { - final deleted = []; - await deleteBookWithMediaCleanup( - dataStore: dataStore, - mediaUploadQueue: queue, - bookId: book.id, - coverMediaId: 'asset-1', - deleteBookFile: (_) async {}, - deletePendingCover: (bookId) async => deleted.add('pending:$bookId'), - deleteGuestCover: (bookId) async => deleted.add('guest:$bookId'), - deleteCoverFile: (mediaId) async => deleted.add('cached:$mediaId'), - ); - expect(deleted, ['pending:${book.id}', 'guest:${book.id}', 'cached:asset-1']); -}); -``` - -- [ ] **Step 2: Run cleanup tests and verify RED** - -```bash -cd app -flutter test test/services/book_delete_cleanup_service_test.dart --reporter expanded -``` - -Expected: FAIL because pending and guest deletion callbacks are missing. - -- [ ] **Step 3: Implement best-effort cleanup** - -Add callbacks for pending and guest files. The page supplies only the callbacks appropriate to its current library mode, but the cleanup service remains idempotent and catches each filesystem failure independently before deleting metadata. - -- [ ] **Step 4: Run cleanup and page deletion tests** - -```bash -cd app -flutter test test/services/book_delete_cleanup_service_test.dart test/pages/book_details_delete_test.dart --reporter expanded -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/services/book_delete_cleanup_service.dart app/lib/pages/book_details_page.dart app/test/services/book_delete_cleanup_service_test.dart -git commit -m "fix: clean pending and guest covers on delete" -``` - -### Task 7: Verify metadata safety, cross-device behavior, and the web runtime - -**Files:** - -- Test: `app/test/powersync/powersync_book_mapper_test.dart` -- Test: `app/test/services/book_import_commit_service_test.dart` - -- [ ] **Step 1: Add the final metadata regression assertion** - -Add an assertion to the import commit test and mapper coverage that a newly imported account book serializes with: - -```dart -expect(book.coverUrl, isNull); -expect(book.coverMediaId, isNull); // until upload returns an asset -expect(book.toJson().toString(), isNot(contains('data:image'))); -``` - -Also retain the existing test where a row containing only `cover_media_id` is mapped and rendered through the lazy authenticated loader, representing a second device. - -- [ ] **Step 2: Run full automated verification** - -```bash -cd app -flutter test --reporter expanded -flutter analyze -dart format --output=none --set-exit-if-changed lib test -node --check web/book_worker.js -git diff --check -``` - -Expected: all tests pass, analysis reports no issues, formatting changes zero files, worker syntax exits 0, and diff check is clean. - -- [ ] **Step 3: Perform a clean web restart and manual two-mode smoke test** - -Do a full browser reload or restart the Flutter web process so no old PowerSync worker/BroadcastChannel instance remains. Do not use hot reload for this check. - -Account flow: - -1. Import a covered EPUB while signed in. -2. Confirm the cover remains visible before upload finishes. -3. Confirm the server `books` row is created with `cover_image_url IS NULL`. -4. Confirm `/v1/media` eventually returns 201 and `cover_media_id` becomes non-null. -5. Open the same account in a clean browser profile/device and confirm the first display downloads the cover and a later display uses local OPFS. - -Guest flow: - -1. Import a covered EPUB in fully offline guest mode. -2. Reload the browser and confirm the cover remains visible from guest OPFS. -3. Sign in and confirm the guest book and cover do not appear in the account library. - -Expected: no repeated media 404, no disappearing cover, no newly created `data:image` metadata, and no `LegacyJavaScriptObject` error after a clean worker restart. If the PowerSync type error remains after a clean restart, capture it as a separate dependency/runtime issue rather than masking it in cover code. - -- [ ] **Step 4: Commit final regression coverage if Step 1 changed tests** - -```bash -git add app/test/powersync/powersync_book_mapper_test.dart app/test/services/book_import_commit_service_test.dart -git commit -m "test: guard filesystem-only cover metadata" -``` diff --git a/docs/superpowers/plans/2026-07-11-media-storage-hardening.md b/docs/superpowers/plans/2026-07-11-media-storage-hardening.md deleted file mode 100644 index 1f5bf8b..0000000 --- a/docs/superpowers/plans/2026-07-11-media-storage-hardening.md +++ /dev/null @@ -1,782 +0,0 @@ -# Media Storage Hardening Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make authenticated covers persist in scoped local files, serialize and scope client uploads, close the enqueue race, harden server upload concurrency, and restore client CI while preserving separate guest libraries. - -**Architecture:** Introduce a shared `MediaStorageScope` for server/account isolation, extend the existing native-filesystem/web-OPFS book storage service with scoped cover-file operations, and route every private cover through one lazy persistent loader. Make `MediaUploadQueue` scope-aware and single-flight, then serialize server uploads with a PostgreSQL user-row lock plus a unique `(book_id, kind)` invariant. - -**Tech Stack:** Flutter/Dart, SharedPreferences, native filesystem, web OPFS/Web Worker, PowerSync, FastAPI, async SQLAlchemy, PostgreSQL, Alembic, pytest. - ---- - -## File structure - -Client files to create: - -- `app/lib/media/media_storage_scope.dart`: immutable server/user scope and safe persistence key. -- `app/lib/widgets/book/private_book_cover.dart`: reusable public/private/local cover renderer. -- `app/test/media/media_storage_scope_test.dart`: scope identity and normalization tests. -- `app/test/widgets/book/private_book_cover_test.dart`: lazy cache and placeholder widget tests. - -Client files to modify: - -- `app/lib/services/book_import_service_stub.dart`: native scoped cover-file storage. -- `app/lib/services/book_import_service.dart`: web worker cover-file request methods. -- `app/web/book_worker.js`: scoped OPFS cover read/write/delete/clear actions. -- `app/lib/media/media_cache_service.dart`: lazy cover cache and in-flight coalescing. -- `app/lib/media/media_upload_queue.dart`: scoped keys, scope activation, single-flight processing, and persisted-work callback. -- `app/lib/main.dart`: scope lifecycle, stable repository capture, and queue callback wiring. -- `app/lib/services/book_delete_cleanup_service.dart`: best-effort cached-cover deletion. -- `app/lib/widgets/book_details/book_cover_image.dart`: compose the shared cover renderer. -- Cover surfaces under `app/lib/widgets/library/`, `app/lib/widgets/dashboard/`, `app/lib/widgets/context_menu/`, `app/lib/widgets/shelves/`, and `app/lib/widgets/topics/`: pass `coverMediaId` to the shared renderer. -- Existing media, deletion, widget, profile-switch, and context-menu tests. - -Server files to modify: - -- `papyrus/models/media.py`: unique `(book_id, kind)` model invariant. -- `alembic/versions/c3f8b2a9d1e4_add_media_assets.py`: matching unique constraint in the unmerged revision. -- `papyrus/services/media.py`: per-user transaction lock and locked book lookup. -- `tests/api/routes/test_media.py`: concurrent replacement and quota tests. -- `tests/test_models.py`: metadata constraint assertion. - -### Task 1: Restore the intended context-menu contract - -**Files:** - -- Modify: `app/test/widgets/context_menu/book_context_menu_test.dart:11-45` -- Verify: `app/lib/widgets/context_menu/book_context_menu.dart:421-442` - -- [ ] **Step 1: Change the failing test to assert the approved labels** - -```dart -final downloadTop = tester.getTopLeft(find.text('Download')).dy; -final deleteTop = tester.getTopLeft(find.text('Delete')).dy; -expect(downloadTop, lessThan(deleteTop)); - -await tester.tap(find.text('Download')); -``` - -- [ ] **Step 2: Run the focused test and verify it passes without production changes** - -Run: `cd app && flutter test test/widgets/context_menu/book_context_menu_test.dart` - -Expected: one passing test. This is an assertion correction for an already-approved UI contract, so no red production cycle is required. - -- [ ] **Step 3: Commit the CI correction** - -```bash -git add app/test/widgets/context_menu/book_context_menu_test.dart -git commit -m "test: align book menu labels" -``` - -### Task 2: Define authenticated media scope - -**Files:** - -- Create: `app/lib/media/media_storage_scope.dart` -- Create: `app/test/media/media_storage_scope_test.dart` - -- [ ] **Step 1: Write failing scope tests** - -```dart -test('scope key isolates server and user', () { - final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - final second = MediaStorageScope(profileKey: 'custom-deadbeef', userId: 'user-1'); - - expect(first.persistenceKey, isNot(second.persistenceKey)); - expect(first.persistenceKey, 'official--user-1'); -}); - -test('scope rejects path separators', () { - expect( - () => MediaStorageScope(profileKey: '../official', userId: 'user-1'), - throwsArgumentError, - ); -}); -``` - -- [ ] **Step 2: Run the test and verify RED** - -Run: `cd app && flutter test test/media/media_storage_scope_test.dart` - -Expected: compilation failure because `MediaStorageScope` does not exist. - -- [ ] **Step 3: Add the immutable scope type** - -```dart -class MediaStorageScope { - MediaStorageScope({required this.profileKey, required this.userId}) { - if (!_safePart.hasMatch(profileKey) || !_safePart.hasMatch(userId)) { - throw ArgumentError('Media storage scope contains unsafe characters'); - } - } - - static final RegExp _safePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); - - final String profileKey; - final String userId; - - String get persistenceKey => '$profileKey--$userId'; - - @override - bool operator ==(Object other) => - other is MediaStorageScope && other.profileKey == profileKey && other.userId == userId; - - @override - int get hashCode => Object.hash(profileKey, userId); -} -``` - -- [ ] **Step 4: Run the focused test and verify GREEN** - -Run: `cd app && flutter test test/media/media_storage_scope_test.dart` - -Expected: all tests pass. - -- [ ] **Step 5: Commit the scope type** - -```bash -git add app/lib/media/media_storage_scope.dart app/test/media/media_storage_scope_test.dart -git commit -m "feat: define scoped media identity" -``` - -### Task 3: Add filesystem and OPFS cover-file operations - -**Files:** - -- Modify: `app/lib/services/book_import_service_stub.dart` -- Modify: `app/lib/services/book_import_service.dart` -- Modify: `app/web/book_worker.js` -- Create: `app/test/services/book_cover_storage_test.dart` -- Modify: `app/test/services/book_import_service_test.dart` - -- [ ] **Step 1: Write failing native cover-storage tests with an injected root directory** - -```dart -test('native cover storage is isolated by scope', () async { - final root = await Directory.systemTemp.createTemp('papyrus-covers-'); - addTearDown(() => root.delete(recursive: true)); - final service = BookImportService(storageRootOverride: root); - final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); - final bytes = Uint8List.fromList([1, 2, 3]); - - await service.storeCoverFile(first, 'asset-1', bytes); - - expect(await service.getCoverFile(first, 'asset-1'), bytes); - expect(await service.getCoverFile(second, 'asset-1'), isNull); -}); - -test('clearCoverFiles removes only the selected scope', () async { - final root = await Directory.systemTemp.createTemp('papyrus-covers-'); - addTearDown(() => root.delete(recursive: true)); - final service = BookImportService(storageRootOverride: root); - final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); - - await service.storeCoverFile(first, 'asset-1', Uint8List.fromList([1])); - await service.storeCoverFile(second, 'asset-2', Uint8List.fromList([2])); - await service.clearCoverFiles(first); - - expect(await service.getCoverFile(first, 'asset-1'), isNull); - expect(await service.getCoverFile(second, 'asset-2'), Uint8List.fromList([2])); -}); -``` - -- [ ] **Step 2: Run the native tests and verify RED** - -Run: `cd app && flutter test test/services/book_cover_storage_test.dart` - -Expected: missing constructor parameter and cover methods. - -- [ ] **Step 3: Implement native scoped file operations** - -Add methods with these signatures: - -```dart -Future getCoverFile(MediaStorageScope scope, String mediaId); -Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes); -Future deleteCoverFile(MediaStorageScope scope, String mediaId); -Future clearCoverFiles(MediaStorageScope scope); -``` - -Use `/media-covers//.bin`. Validate `mediaId` with the same safe-component rule. Write `.tmp`, flush it, then rename it over the destination. Add `Directory? storageRootOverride` for tests; production falls back to `getApplicationSupportDirectory()`. - -- [ ] **Step 4: Run native tests and verify GREEN** - -Run: `cd app && flutter test test/services/book_cover_storage_test.dart test/services/book_import_service_test.dart` - -Expected: all tests pass. - -- [ ] **Step 5: Write a failing worker-contract test** - -```dart -test('book worker declares scoped cover cache actions', () { - final source = File('web/book_worker.js').readAsStringSync(); - for (final action in ['getCover', 'storeCover', 'deleteCover', 'clearCovers']) { - expect(source, contains("case '$action':")); - } -}); -``` - -- [ ] **Step 6: Run the worker-contract test and verify RED** - -Run: `cd app && flutter test test/services/book_cover_storage_test.dart` - -Expected: missing `getCover` action. - -- [ ] **Step 7: Extend the OPFS worker and web service** - -Add `getCover`, `storeCover`, `deleteCover`, and `clearCovers` messages containing `scopeKey`, `mediaId`, and a stable `requestId`. Store files under the OPFS `media-covers//` directory. Update the Dart pending map to key cover requests by `requestId`, transfer downloaded/stored `ArrayBuffer` values, and expose the same four Dart method signatures as the native implementation. - -- [ ] **Step 8: Verify worker syntax and focused Flutter tests** - -Run: `node --check app/web/book_worker.js` - -Run: `cd app && flutter test test/services/book_cover_storage_test.dart test/services/book_import_service_test.dart` - -Expected: JavaScript syntax succeeds and all focused tests pass. - -- [ ] **Step 9: Commit platform cover storage** - -```bash -git add app/lib/services/book_import_service.dart app/lib/services/book_import_service_stub.dart app/web/book_worker.js app/test/services/book_cover_storage_test.dart app/test/services/book_import_service_test.dart -git commit -m "feat: persist scoped covers in local files" -``` - -### Task 4: Add lazy persistent cover loading and shared rendering - -**Files:** - -- Modify: `app/lib/media/media_cache_service.dart` -- Modify: `app/test/media/media_cache_service_test.dart` -- Create: `app/lib/widgets/book/private_book_cover.dart` -- Create: `app/test/widgets/book/private_book_cover_test.dart` -- Modify: `app/lib/widgets/book_details/book_cover_image.dart` -- Modify representative and remaining cover surfaces found by `rg -n "coverURL|coverUrl" app/lib/widgets app/lib/pages` - -- [ ] **Step 1: Write failing lazy-cache and coalescing tests** - -```dart -test('cover download persists and the next load reads local bytes', () async { - final service = MediaCacheService(); - final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - Uint8List? stored; - var downloads = 0; - - final first = await service.ensureCoverCached( - scope: scope, - mediaId: 'asset-1', - readLocalCover: (_, __) async => stored, - writeLocalCover: (_, __, bytes) async => stored = bytes, - downloadMedia: (_) async { - downloads++; - return Uint8List.fromList([1, 2, 3]); - }, - ); - final second = await service.ensureCoverCached( - scope: scope, - mediaId: 'asset-1', - readLocalCover: (_, __) async => stored, - writeLocalCover: (_, __, bytes) async => stored = bytes, - downloadMedia: (_) async { - downloads++; - return Uint8List.fromList([1, 2, 3]); - }, - ); - - expect(first, second); - expect(downloads, 1); -}); - -test('overlapping cover requests share one download', () async { - final service = MediaCacheService(); - final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - final gate = Completer(); - var downloads = 0; - - Future load() => service.ensureCoverCached( - scope: scope, - mediaId: 'asset-1', - readLocalCover: (_, __) async => null, - writeLocalCover: (_, __, ___) async {}, - downloadMedia: (_) { - downloads++; - return gate.future; - }, - ); - - final first = load(); - final second = load(); - gate.complete(Uint8List.fromList([1, 2, 3])); - - expect(await first, await second); - expect(downloads, 1); -}); -``` - -- [ ] **Step 2: Run cache tests and verify RED** - -Run: `cd app && flutter test test/media/media_cache_service_test.dart` - -Expected: `ensureCoverCached` is missing. - -- [ ] **Step 3: Implement cover caching in `MediaCacheService`** - -Use a `Map>` keyed by `:`. Read local bytes first; otherwise coalesce download/write work. Remove the map entry in `whenComplete` so the filesystem remains the durable cache. - -- [ ] **Step 4: Run cache tests and verify GREEN** - -Run: `cd app && flutter test test/media/media_cache_service_test.dart` - -Expected: all book-file and cover-cache tests pass. - -- [ ] **Step 5: Write failing widget tests for a private cover** - -```dart -testWidgets('private cover loads from scoped local cache', (tester) async { - final bytes = Uint8List.fromList([137, 80, 78, 71]); - var loads = 0; - await tester.pumpWidget( - MaterialApp( - home: CoverImage( - mediaId: 'asset-1', - loadPrivateCover: (_) async { - loads++; - return bytes; - }, - placeholder: const Icon(Icons.menu_book), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(Image), findsOneWidget); - expect(loads, 1); -}); - -testWidgets('private cover falls back to placeholder when signed out', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: CoverImage( - mediaId: 'asset-1', - placeholder: Icon(Icons.menu_book, key: Key('placeholder')), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('placeholder')), findsOneWidget); -}); -``` - -- [ ] **Step 6: Run widget tests and verify RED** - -Run: `cd app && flutter test test/widgets/book/private_book_cover_test.dart` - -Expected: `CoverImage` does not exist. - -- [ ] **Step 7: Implement `CoverImage` and integrate `CoverImagePreview`** - -The stateful widget accepts `imageUrl`, `mediaId`, `fit`, and a placeholder builder. It caches its future until `mediaId` or scope changes, uses the active `MediaStorageScope`, reads/writes through `BookImportService`, downloads through `AuthProvider`, and delegates lazy caching to `MediaCacheService`. - -- [ ] **Step 8: Replace private-cover-blind surfaces** - -Use `CoverImage` in library cards/list rows, recently-added and continue-reading dashboard cards, move-to-shelf and manage-topics sheets, and `BookContextMenu`. Pass both `book.coverURL` and `book.coverMediaId`; preserve each surface's existing dimensions, fit, and placeholder styling. - -- [ ] **Step 9: Run focused cover widget tests and analyzer** - -Run: `cd app && flutter test test/widgets/book/private_book_cover_test.dart test/widgets/library/book_card_test.dart test/widgets/library/book_list_item_test.dart` - -Run: `cd app && flutter analyze` - -Expected: focused tests and analysis pass. - -- [ ] **Step 10: Commit lazy cover rendering** - -```bash -git add app/lib/media/media_cache_service.dart app/lib/widgets app/test/media/media_cache_service_test.dart app/test/widgets -git commit -m "feat: render private covers from persistent cache" -``` - -### Task 5: Scope upload tasks and make processing single-flight - -**Files:** - -- Modify: `app/lib/media/media_upload_queue.dart` -- Modify: `app/test/media/media_upload_queue_test.dart` - -- [ ] **Step 1: Write failing scope-isolation tests** - -```dart -test('pending uploads are isolated and restored per media scope', () async { - final queue = MediaUploadQueue(prefs); - final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - final second = MediaStorageScope(profileKey: 'custom-a', userId: 'user-2'); - - await queue.activateScope(first); - await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); - await queue.activateScope(second); - expect(queue.pendingTasks, isEmpty); - await queue.activateScope(first); - expect(queue.pendingTasks, hasLength(1)); -}); -``` - -- [ ] **Step 2: Run the isolation test and verify RED** - -Run: `cd app && flutter test test/media/media_upload_queue_test.dart --plain-name "pending uploads are isolated"` - -Expected: `activateScope` is missing. - -- [ ] **Step 3: Implement scoped persistence** - -Replace the global key with `media_upload_queue:`. `activateScope(null)` exposes an empty queue. Loading malformed JSON should preserve app startup by returning an empty task list and reporting through `FlutterError.reportError`. - -- [ ] **Step 4: Run queue tests and verify scope GREEN** - -Run: `cd app && flutter test test/media/media_upload_queue_test.dart` - -Expected: scope tests pass; update existing assertions to inspect the scoped key. - -- [ ] **Step 5: Write a failing overlapping-processing test** - -```dart -test('overlapping processPending calls share one upload operation', () async { - final gate = Completer(); - var uploads = 0; - Future process() => queue.processPending( - dataStore: dataStore, - readBookFile: (_) async => Uint8List.fromList([1, 2, 3]), - uploadMedia: (_) { - uploads++; - return gate.future; - }, - ); - - final first = process(); - final second = process(); - expect(identical(first, second), isTrue); - gate.complete(asset); - await Future.wait([first, second]); - expect(uploads, 1); -}); -``` - -- [ ] **Step 6: Run the overlap test and verify RED** - -Run: `cd app && flutter test test/media/media_upload_queue_test.dart --plain-name "overlapping processPending"` - -Expected: two uploads or non-identical futures. - -- [ ] **Step 7: Implement single-flight processing** - -Track `Future? _processing`. Return the current future when non-null, capture the active scope at operation start, and clear the field only when the identical operation completes. Expose `Future waitUntilIdle()` for profile changes. - -- [ ] **Step 8: Run all queue tests and verify GREEN** - -Run: `cd app && flutter test test/media/media_upload_queue_test.dart` - -Expected: all tests pass. - -- [ ] **Step 9: Commit queue isolation and serialization** - -```bash -git add app/lib/media/media_upload_queue.dart app/test/media/media_upload_queue_test.dart -git commit -m "fix: scope and serialize media uploads" -``` - -### Task 6: Trigger uploads after durable enqueue and coordinate profile changes - -**Files:** - -- Modify: `app/lib/media/media_upload_queue.dart` -- Modify: `app/lib/main.dart` -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` -- Modify: `app/test/media/media_upload_queue_test.dart` -- Modify: `app/test/powersync/storage_sync_controller_test.dart` -- Modify or create: `app/test/main_media_lifecycle_test.dart` - -- [ ] **Step 1: Write a failing persisted-work callback test** - -```dart -test('enqueue invokes work callback after scoped tasks are persisted', () async { - String? storedAtCallback; - late SharedPreferences prefs; - final queue = MediaUploadQueue( - prefs, - onWorkAvailable: () async { - storedAtCallback = prefs.getString('media_upload_queue:${scope.persistenceKey}'); - }, - ); - - await queue.activateScope(scope); - await queue.enqueueBookFile( - book: book, - filename: 'book.epub', - contentType: 'application/epub+zip', - ); - expect(storedAtCallback, isNotNull); - expect(jsonDecode(storedAtCallback!), hasLength(1)); -}); -``` - -- [ ] **Step 2: Run the callback test and verify RED** - -Run: `cd app && flutter test test/media/media_upload_queue_test.dart --plain-name "enqueue invokes work callback"` - -Expected: constructor does not accept `onWorkAvailable`. - -- [ ] **Step 3: Implement the post-persistence callback** - -Add `Future Function()? onWorkAvailable`. Invoke it after `_save()` in `_enqueue` and after `retryFailed` makes at least one task pending. Do not invoke it from internal task-status saves. - -- [ ] **Step 4: Run queue tests and verify GREEN** - -Run: `cd app && flutter test test/media/media_upload_queue_test.dart` - -Expected: all tests pass. - -- [ ] **Step 5: Write failing lifecycle tests** - -Cover three concrete cases in `main_media_lifecycle_test.dart`: a signed-in import persists a task before its fake uploader is invoked; a server switch remains incomplete while a fake upload `Completer` is pending and activates the new scope after completion; and guest activation leaves `MediaUploadQueue.activeScope` null with no visible authenticated tasks. Build the harness with injected fake `AuthRepository`, `PapyrusPowerSyncService`, and uploader closures, then assert invocation order through a shared event list. - -- [ ] **Step 6: Run lifecycle tests and verify RED** - -Run: `cd app && flutter test test/main_media_lifecycle_test.dart` - -Expected: missing scope activation/callback wiring. - -- [ ] **Step 7: Wire scope and processing lifecycle in `main.dart`** - -Construct `MediaStorageScope(profileKey: _activeProfileKey, userId: user.userId)` only for signed-in account mode. Activate it before processing. Capture `final repository = _authRepository` inside each processor operation. Before replacing the repository during a server switch, await `_mediaUploadQueue.waitUntilIdle()`. Wire `onWorkAvailable` to `_processMediaUploads`; single-flight makes authentication and PowerSync triggers safe. - -Remove any direct process call from the import widget if the queue callback makes it redundant. Keep the import sequence awaiting both book-file and cover enqueue operations so the callback sees durable work. - -- [ ] **Step 8: Run lifecycle and import tests** - -Run: `cd app && flutter test test/main_media_lifecycle_test.dart test/services/book_import_service_test.dart test/media/media_upload_queue_test.dart` - -Expected: all tests pass. - -- [ ] **Step 9: Commit enqueue/lifecycle coordination** - -```bash -git add app/lib/main.dart app/lib/media/media_upload_queue.dart app/lib/widgets/add_book/import_book_sheet.dart app/test -git commit -m "fix: process media immediately after enqueue" -``` - -### Task 7: Clean cached covers with book/account cache deletion - -**Files:** - -- Modify: `app/lib/services/book_delete_cleanup_service.dart` -- Modify callers in `app/lib/pages/book_details_page.dart`, `app/lib/utils/book_actions.dart`, and `app/lib/utils/bulk_book_actions.dart` -- Modify: `app/test/services/book_delete_cleanup_service_test.dart` -- Modify authenticated-cache clearing path in `app/lib/main.dart` or `app/lib/powersync/storage_sync_controller.dart` - -- [ ] **Step 1: Write a failing deletion test** - -```dart -test('deleteBookWithMediaCleanup removes the scoped cached cover', () async { - final deletedCovers = []; - await deleteBookWithMediaCleanup( - dataStore: dataStore, - mediaUploadQueue: queue, - bookId: book.id, - coverMediaId: 'cover-1', - deleteBookFile: (_) async {}, - deleteCoverFile: (mediaId) async => deletedCovers.add(mediaId), - ); - expect(deletedCovers, ['cover-1']); -}); -``` - -- [ ] **Step 2: Run the deletion test and verify RED** - -Run: `cd app && flutter test test/services/book_delete_cleanup_service_test.dart` - -Expected: missing cover parameters. - -- [ ] **Step 3: Implement best-effort cover cleanup and update callers** - -Remove scoped upload tasks first, then independently attempt local book-file and cover-file deletion, finally delete metadata. Pass the current book's `coverMediaId` and a closure bound to the active `MediaStorageScope`. - -- [ ] **Step 4: Add and pass an authenticated-cache-clear test** - -Assert clearing the active authenticated cache invokes `clearCoverFiles(activeScope)` but guest clearing does not. - -- [ ] **Step 5: Run deletion/profile tests and commit** - -Run: `cd app && flutter test test/services/book_delete_cleanup_service_test.dart test/pages/book_details_delete_test.dart test/powersync/storage_sync_controller_test.dart` - -Expected: all tests pass. - -```bash -git add app/lib app/test -git commit -m "fix: clean scoped cover files" -``` - -### Task 8: Enforce one server asset per book kind and serialize quota checks - -**Files:** - -- Modify: `../server/papyrus/models/media.py` -- Modify: `../server/alembic/versions/c3f8b2a9d1e4_add_media_assets.py` -- Modify: `../server/papyrus/services/media.py` -- Modify: `../server/tests/test_models.py` -- Modify: `../server/tests/api/routes/test_media.py` - -- [ ] **Step 1: Write a failing metadata constraint test** - -```python -def test_media_asset_kind_is_unique_per_book() -> None: - table = Base.metadata.tables['media_assets'] - unique_columns = { - tuple(constraint.columns.keys()) - for constraint in table.constraints - if isinstance(constraint, UniqueConstraint) - } - assert ('book_id', 'kind') in unique_columns -``` - -- [ ] **Step 2: Run the model test and verify RED** - -Run: `cd ../server && .venv/bin/pytest tests/test_models.py -q` - -Expected: the unique column pair is absent. - -- [ ] **Step 3: Add the SQLAlchemy and Alembic constraint** - -```python -__table_args__ = ( - UniqueConstraint('book_id', 'kind', name='uq_media_assets_book_kind'), -) -``` - -Add the equivalent `sa.UniqueConstraint` to `c3f8b2a9d1e4_add_media_assets.py`. Do not create a new revision because this revision is unmerged and undeployed. - -- [ ] **Step 4: Run the model test and verify GREEN** - -Run: `cd ../server && .venv/bin/pytest tests/test_models.py -q` - -Expected: all model tests pass. - -- [ ] **Step 5: Write failing concurrent upload tests** - -Using `test_session_maker`, create two independent `AsyncSession` instances and start two `media_service.upload_media` coroutines with `asyncio.gather`. In `test_concurrent_same_kind_uploads_leave_one_asset`, target one user/book/kind and assert exactly one `MediaAsset` row, the book references that row, and exactly one physical file remains. In `test_concurrent_uploads_enforce_aggregate_user_quota`, target two books owned by one user with two files whose combined size exceeds the configured quota; assert one coroutine succeeds, one returns `ConflictError`, and both the SQL sum and physical-file sum remain at or below quota. - -- [ ] **Step 6: Run concurrent tests and verify RED** - -Run: `cd ../server && .venv/bin/pytest tests/api/routes/test_media.py -q -k concurrent` - -Expected: duplicate assets or quota overflow. - -- [ ] **Step 7: Lock the user and book rows before quota/replacement reads** - -Add transaction helpers equivalent to: - -```python -await session.execute( - select(User.user_id).where(User.user_id == user_id).with_for_update() -) -book = ( - await session.execute( - select(SyncBook) - .where(SyncBook.book_id == book_id) - .with_for_update() - ) -).scalar_one_or_none() -``` - -Acquire the user lock first for every upload, then validate the locked book, calculate usage, stream/write, replace, and commit. Keep the existing rollback cleanup and post-commit old-file deletion. - -- [ ] **Step 8: Run media and sync tests and verify GREEN** - -Run: `cd ../server && .venv/bin/pytest tests/api/routes/test_media.py tests/api/routes/test_sync.py -q` - -Expected: all focused server tests pass. - -- [ ] **Step 9: Verify migration shape** - -Run: `cd ../server && .venv/bin/alembic heads` - -Expected: one head, `c3f8b2a9d1e4`. - -Run, when the runbook database is available: - -```bash -cd ../server -.venv/bin/alembic downgrade a1d7c2f4e8b9 -.venv/bin/alembic upgrade head -.venv/bin/alembic downgrade a1d7c2f4e8b9 -.venv/bin/alembic upgrade head -``` - -Expected: every command succeeds and the final revision is `c3f8b2a9d1e4`. - -- [ ] **Step 10: Commit server hardening** - -```bash -cd ../server -git add papyrus/models/media.py papyrus/services/media.py alembic/versions/c3f8b2a9d1e4_add_media_assets.py tests/test_models.py tests/api/routes/test_media.py -git commit -m "fix: serialize media storage updates" -``` - -### Task 9: Full verification and PR readiness - -**Files:** - -- Review all files changed by Tasks 1-8. - -- [ ] **Step 1: Run client formatting and diff checks** - -Run: `cd app && dart format --output=none --set-exit-if-changed lib test` - -Run: `git diff --check origin/master...HEAD` - -Expected: both pass. - -- [ ] **Step 2: Run full client verification** - -Run: `cd app && flutter analyze` - -Run: `cd app && flutter test` - -Run: `node --check app/web/book_worker.js` - -Expected: no analyzer issues and all tests pass. - -- [ ] **Step 3: Run server formatting/lint and full tests** - -Run: `cd ../server && .venv/bin/ruff format --check papyrus tests alembic` - -Run: `cd ../server && .venv/bin/ruff check papyrus tests alembic` - -Run: `cd ../server && .venv/bin/pytest -q` - -Expected: formatting and lint pass; all non-provider tests pass with only the established opt-in skips. - -- [ ] **Step 4: Confirm clean scoped behavior manually from code paths** - -Check that: - -- guest activation uses no `MediaStorageScope`; -- sign-out does not delete another account's queue/cache; -- server switch waits for queue idle and captures the old repository; -- every book-cover surface found by `rg -n "coverURL|coverUrl" app/lib/widgets app/lib/pages` either uses `CoverImage` or intentionally renders a public-only decorative URL; -- no cover bytes are stored in SQL or SharedPreferences. - -- [ ] **Step 5: Inspect GitHub checks without changing remote state** - -Run: - -```bash -gh pr checks 16 --repo PapyrusReader/client -gh pr checks 3 --repo PapyrusReader/server -``` - -Expected: existing remote checks may still reflect prior commits until pushed; report this distinction explicitly. - -- [ ] **Step 6: Report final commits, test counts, migration caveats, and any residual risk** - -Do not claim either PR passes until the fresh commands above prove it locally. Do not push or submit GitHub reviews unless separately requested. diff --git a/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md b/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md deleted file mode 100644 index cf15507..0000000 --- a/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md +++ /dev/null @@ -1,373 +0,0 @@ -# Stable Local Cover Image Cache Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Eliminate cover flashes between Papyrus pages by giving filesystem-backed covers stable Flutter image-cache identities. - -**Architecture:** Add a `LocalCoverImageProvider` whose immutable key contains storage scope, bucket, and file ID. `CoverImage` will resolve local account, pending, and guest covers through that provider so Flutter's bounded global `ImageCache` reuses decoded frames across widget instances; OPFS/native files remain authoritative and existing authenticated lazy download remains the cached-media loader. - -**Tech Stack:** Flutter/Dart, `ImageProvider`, `MultiFrameImageStreamCompleter`, Flutter `ImageCache`, native filesystem, browser OPFS, widget/unit tests. - ---- - -## File structure - -- Create `app/lib/media/local_cover_image_provider.dart`: stable cover key, asynchronous byte decoding, and targeted cache eviction. -- Create `app/test/media/local_cover_image_provider_test.dart`: key isolation, loader reuse, failures, and eviction. -- Modify `app/lib/widgets/book/private_book_cover.dart`: replace provider-backed `FutureBuilder` reads with stable local image providers while retaining public and injected compatibility paths. -- Modify `app/test/widgets/book/private_book_cover_test.dart`: verify separately mounted library/details widgets reuse the decoded image without another storage read or placeholder frame. -- Modify `app/lib/services/book_import_service.dart`: evict web cover keys when same-identity files are overwritten or deleted. -- Modify `app/lib/services/book_import_service_stub.dart`: matching native eviction behavior. -- Modify `app/test/services/book_cover_storage_test.dart`: verify storage replacement and deletion invalidate decoded keys. - -### Task 1: Add a stable filesystem-backed cover image provider - -**Files:** - -- Create: `app/lib/media/local_cover_image_provider.dart` -- Create: `app/test/media/local_cover_image_provider_test.dart` - -- [ ] **Step 1: Write failing provider cache-key tests** - -Create tests around a valid one-pixel PNG and clear `PaintingBinding.instance.imageCache` in setup/teardown: - -```dart -testWidgets('equal local cover keys reuse one filesystem load', (tester) async { - var loads = 0; - Future load() async { - loads++; - return pngBytes; - } - - final first = LocalCoverImageProvider( - scopeKey: 'official--user-1', - bucket: CoverStorageBucket.cached, - fileId: 'asset-1', - loadBytes: load, - ); - final second = LocalCoverImageProvider( - scopeKey: 'official--user-1', - bucket: CoverStorageBucket.cached, - fileId: 'asset-1', - loadBytes: load, - ); - - await tester.pumpWidget(MaterialApp(home: Image(image: first))); - await tester.pumpAndSettle(); - await tester.pumpWidget(const SizedBox()); - await tester.pump(); - await tester.pumpWidget(MaterialApp(home: Image(image: second))); - await tester.pumpAndSettle(); - - expect(loads, 1); -}); - -test('scope bucket and file id participate in key equality', () { - expect(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'x').key, - isNot(_provider(scope: 'two', bucket: CoverStorageBucket.cached, id: 'x').key)); - expect(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'x').key, - isNot(_provider(scope: 'one', bucket: CoverStorageBucket.pending, id: 'x').key)); - expect(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'x').key, - isNot(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'y').key)); -}); -``` - -Add tests that null bytes report an image-stream error and `evict` causes the next equal provider to invoke its loader again. - -- [ ] **Step 2: Run the provider test and verify RED** - -Run: - -```bash -cd app -flutter test test/media/local_cover_image_provider_test.dart --reporter expanded -``` - -Expected: compilation fails because `LocalCoverImageProvider` does not exist. - -- [ ] **Step 3: Implement the stable key and provider** - -Create an immutable key and provider using Flutter 3.41's `loadImage` API: - -```dart -@immutable -class LocalCoverImageKey { - const LocalCoverImageKey({required this.scopeKey, required this.bucket, required this.fileId}); - - final String scopeKey; - final CoverStorageBucket bucket; - final String fileId; - - @override - bool operator ==(Object other) => - other is LocalCoverImageKey && - other.scopeKey == scopeKey && - other.bucket == bucket && - other.fileId == fileId; - - @override - int get hashCode => Object.hash(scopeKey, bucket, fileId); -} - -class LocalCoverImageProvider extends ImageProvider { - LocalCoverImageProvider({ - required String scopeKey, - required CoverStorageBucket bucket, - required String fileId, - required this.loadBytes, - }) : key = LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId); - - final LocalCoverImageKey key; - final Future Function() loadBytes; - - @override - Future obtainKey(ImageConfiguration configuration) => SynchronousFuture(key); - - @override - ImageStreamCompleter loadImage(LocalCoverImageKey key, ImageDecoderCallback decode) { - return MultiFrameImageStreamCompleter(codec: _load(decode), scale: 1, debugLabel: key.toString()); - } - - Future _load(ImageDecoderCallback decode) async { - final bytes = await loadBytes(); - if (bytes == null || bytes.isEmpty) throw StateError('Local cover file was not found'); - return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); - } - - static bool evictKey({required String scopeKey, required CoverStorageBucket bucket, required String fileId}) { - return PaintingBinding.instance.imageCache.evict( - LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId), - includeLive: true, - ); - } -} -``` - -Implement `toString` for diagnostics. Keep the loader out of equality so new widget instances with the same storage identity share Flutter's cache entry. - -- [ ] **Step 4: Run provider tests and analysis** - -Run: - -```bash -cd app -flutter test test/media/local_cover_image_provider_test.dart --reporter expanded -flutter analyze -``` - -Expected: all provider tests pass and analysis reports no issues. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/media/local_cover_image_provider.dart app/test/media/local_cover_image_provider_test.dart -git commit -m "feat: add stable local cover image provider" -``` - -### Task 2: Render production local covers through Flutter's image cache - -**Files:** - -- Modify: `app/lib/widgets/book/private_book_cover.dart` -- Modify: `app/test/widgets/book/private_book_cover_test.dart` - -- [ ] **Step 1: Write the failing cross-page reuse test** - -Extend the provider harness with `Provider` and `Provider`. Use a recording import service whose cached-cover loader returns valid PNG bytes. Mount a library-style cover, unmount it, and mount a details-style cover with the same book/media identity: - -```dart -testWidgets('new page reuses decoded private cover without reading storage again', (tester) async { - final harness = await _buildProviderHarness(); - final importService = _RecordingCoverImportService(pngBytes); - - Widget page(Key key) => harness.wrapWithCoverServices( - CoverImage( - key: key, - bookId: 'book-1', - mediaId: 'asset-1', - placeholder: const SizedBox(key: Key('placeholder')), - ), - importService: importService, - ); - - await tester.pumpWidget(page(const Key('library-cover'))); - await tester.pumpAndSettle(); - await tester.pumpWidget(const SizedBox()); - await tester.pump(); - await tester.pumpWidget(page(const Key('details-cover'))); - await tester.pump(); - - expect(find.byKey(const Key('placeholder')), findsNothing); - expect(importService.cachedReads, 1); -}); -``` - -Add equivalent tests for pending account and guest keys, plus a profile-key change test proving it performs a new read. - -- [ ] **Step 2: Run the widget test and verify RED** - -Run: - -```bash -cd app -flutter test test/widgets/book/private_book_cover_test.dart --reporter expanded -``` - -Expected: the second widget performs another asynchronous storage read and exposes the placeholder during its first frame. - -- [ ] **Step 3: Route provider-backed sources through `LocalCoverImageProvider`** - -Retain the current injected-loader `FutureBuilder` path for isolated tests and compatibility. Add `_localImageProvider` and configure it for production sources: - -```dart -_localImageProvider = LocalCoverImageProvider( - scopeKey: scope.persistenceKey, - bucket: CoverStorageBucket.cached, - fileId: mediaId, - loadBytes: () => cacheService.ensureCoverCached( - scope: scope, - mediaId: mediaId, - readLocalCover: importService.getCoverFile, - writeLocalCover: importService.storeCoverFile, - downloadMedia: authProvider.downloadMedia, - ), -); -``` - -Use `CoverStorageBucket.pending` with `getPendingCoverFile` for account book-ID covers and `CoverStorageBucket.guestBooks` with `MediaStorageScope.localGuest.persistenceKey` for guest covers. - -Render the provider with the existing placeholder contract: - -```dart -return Image( - image: provider, - fit: widget.fit, - gaplessPlayback: true, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - return wasSynchronouslyLoaded || frame != null ? child : widget.placeholder; - }, - errorBuilder: (_, _, _) => widget.placeholder, -); -``` - -Clear `_localImageProvider` whenever book ID, media ID, URL, injected loader, auth mode, or profile scope changes. Public URLs and legacy inline data remain unchanged. - -- [ ] **Step 4: Run cover and representative surface tests** - -Run: - -```bash -cd app -flutter test \ - test/widgets/book/private_book_cover_test.dart \ - test/widgets/library/book_card_test.dart \ - test/widgets/library/book_list_item_test.dart \ - test/widgets/book_details/book_cover_image_test.dart \ - --reporter expanded -``` - -Expected: all tests pass, including one storage read across separately mounted pages. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/widgets/book/private_book_cover.dart app/test/widgets/book/private_book_cover_test.dart -git commit -m "fix: reuse decoded covers across page transitions" -``` - -### Task 3: Invalidate decoded covers when local files are removed - -**Files:** - -- Modify: `app/lib/services/book_import_service.dart` -- Modify: `app/lib/services/book_import_service_stub.dart` -- Modify: `app/test/services/book_cover_storage_test.dart` - -- [ ] **Step 1: Write failing invalidation tests** - -Populate `PaintingBinding.instance.imageCache` with a `LocalCoverImageProvider`, then delete the corresponding filesystem cover. Resolve an equal provider again and assert the loader runs a second time. Cover cached, pending, and guest identities; also assert promotion evicts the pending book-ID key. - -- [ ] **Step 2: Run storage tests and verify RED** - -Run: - -```bash -cd app -flutter test test/services/book_cover_storage_test.dart --reporter expanded -``` - -Expected: cached decoded entries survive filesystem deletion and the loader is not called again. - -- [ ] **Step 3: Evict deterministic keys after successful removal** - -After successful store/delete operations, call: - -```dart -LocalCoverImageProvider.evictKey( - scopeKey: scope.persistenceKey, - bucket: bucket, - fileId: id, -); -``` - -Promotion evicts `pending/` after the cached write succeeds. Do not evict after ordinary store operations: a lazy authenticated download stores the same key that is currently resolving, and evicting it would defeat reuse on the next page. Cover replacement already receives a new media ID. Apply matching deletion and promotion behavior to web and native services. Filesystem failure must not evict a still-valid decoded image. - -- [ ] **Step 4: Run storage, media, and cover tests** - -Run: - -```bash -cd app -flutter test test/services/book_cover_storage_test.dart test/media test/widgets/book/private_book_cover_test.dart --reporter expanded -flutter analyze -node --check web/book_worker.js -``` - -Expected: all tests pass, analysis is clean, and worker syntax remains valid. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/services/book_import_service.dart app/lib/services/book_import_service_stub.dart app/test/services/book_cover_storage_test.dart -git commit -m "fix: evict decoded covers after local mutation" -``` - -### Task 4: Final verification and browser smoke test - -**Files:** - -- No production files expected beyond Tasks 1-3. - -- [ ] **Step 1: Run complete automated verification** - -```bash -cd app -flutter test --reporter compact -flutter analyze -dart format --output=none --set-exit-if-changed lib test -node --check web/book_worker.js -git diff --check -``` - -Expected: all tests pass, analysis reports no issues, formatting changes zero files, JavaScript syntax is valid, and the diff check is clean. - -- [ ] **Step 2: Perform live navigation verification** - -With a signed-in library containing a covered book: - -1. Open the library grid and wait for the cover to render once. -2. Open book details and confirm the cover appears without a placeholder flash. -3. Navigate back to the library and confirm the same behavior. -4. Switch between grid and list layouts and confirm no storage reread for the same key. -5. Repeat with a guest book after a first render. - -Use the existing CDP cover trace to confirm subsequent mounts do not issue another `getCover` request for an image still held by Flutter's image cache. - -- [ ] **Step 3: Review branch state** - -```bash -git status --short --branch -git log --oneline -8 -``` - -Expected: the worktree is clean and the new commits are present on `codex/media-storage-pipeline`. diff --git a/docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md b/docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md deleted file mode 100644 index 7bb7de4..0000000 --- a/docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md +++ /dev/null @@ -1,46 +0,0 @@ -# Book Edit Layout Alignment Implementation Plan - -**Goal:** Make the book edit page read as the edit state of the book details page by sharing its left content origin, adding persistent page actions, and preserving the existing responsive form behavior. - -**Architecture:** Keep `BookEditPage` and `BookEditProvider` behavior intact. Restructure only the page composition: a fixed desktop header above a left-aligned, width-constrained scroll area; retain the existing mobile app bar and stacked form. Add widget tests around visible controls and relative geometry rather than implementation-specific widget nesting. - -**Tech Stack:** Flutter, Material, Provider, `flutter_test` - ---- - -### Task 1: Add responsive layout regression tests - -**Files:** -- Create: `app/test/pages/book_edit_page_layout_test.dart` -- Reference: `app/test/helpers/test_helpers.dart` -- Reference: `app/lib/pages/book_edit_page.dart` - -1. Add a desktop test that loads a real test book and verifies the page title, Back action, and Save action. -2. Verify the desktop content begins at the normal page margin instead of being horizontally centered. -3. Verify the cover and fields are side by side and Save is outside the scrolling form content. -4. Add a narrow-screen test verifying the cover section precedes the form fields with no horizontal overflow. -5. Run the test and confirm the desktop assertions fail against the current centered layout. - -### Task 2: Restructure the edit page layout - -**Files:** -- Modify: `app/lib/pages/book_edit_page.dart` - -1. Add a compact desktop page header with a back arrow, `Edit book`, and a text-only primary Save action. -2. Keep the header outside the scroll area so Save remains available on long forms. -3. Replace the centered desktop wrapper with a top-left-aligned container using the same desktop page margin as book details. -4. Preserve the two-column form, using a cover column close to the details-page cover position and a constrained overall width. -5. Remove the disconnected bottom Save button. -6. Preserve the current mobile app bar and stacked form behavior, adding stable semantic labels/keys where needed by the regression tests. - -### Task 3: Verify behavior and quality - -**Files:** -- Test: `app/test/pages/book_edit_page_layout_test.dart` -- Test: existing book edit and provider tests - -1. Format changed Dart files. -2. Run the focused layout test. -3. Run existing book edit/provider tests that cover form and cover behavior. -4. Run Flutter analysis for the changed files or application package. -5. Review the final diff to confirm no form data, validation, or persistence behavior changed. diff --git a/docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md b/docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md deleted file mode 100644 index 20f26e6..0000000 --- a/docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md +++ /dev/null @@ -1,174 +0,0 @@ -# Add Book Bottom Sheets Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Present the add-book choice and digital import flows as content-sized bottom sheets on every screen and show the requested digital format list. - -**Architecture:** Remove desktop dialog branches from both sheet entry points and use the existing modal bottom-sheet components consistently. Keep import behavior unchanged; only presentation and explanatory copy change. - -**Tech Stack:** Flutter, Dart, Material modal bottom sheets, Flutter widget tests - ---- - -### Task 1: Capture desktop bottom-sheet behavior - -**Files:** -- Create: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [ ] **Step 1: Add a desktop test harness and failing choice-sheet test** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart'; -import 'package:papyrus/widgets/add_book/import_book_sheet.dart'; -import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; - -void main() { - Future pumpLauncher(WidgetTester tester, VoidCallback Function(BuildContext) action) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1400, 1000); - addTearDown(tester.view.reset); - - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) => Scaffold( - body: FilledButton(onPressed: action(context), child: const Text('Open')), - ), - ), - ), - ); - } - - testWidgets('add book opens as a bottom sheet on desktop', (tester) async { - await pumpLauncher(tester, (context) => () => AddBookChoiceSheet.show(context)); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.byType(BottomSheet), findsOneWidget); - expect(find.byType(Dialog), findsNothing); - expect(find.byType(BottomSheetHandle), findsOneWidget); - expect(find.text('EPUB, PDF, AZW3, MOBI, CBZ/CBR'), findsOneWidget); - }); -} -``` - -- [ ] **Step 2: Add the failing import-sheet test** - -```dart -testWidgets('import book opens as a format-neutral bottom sheet on desktop', (tester) async { - await pumpLauncher(tester, (context) => () => ImportBookSheet.show(context)); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.byType(BottomSheet), findsOneWidget); - expect(find.byType(Dialog), findsNothing); - expect(find.byType(BottomSheetHandle), findsOneWidget); - expect(find.text('Select a digital book file'), findsOneWidget); - expect(find.text('EPUB, PDF, AZW3, MOBI, CBZ/CBR'), findsOneWidget); - expect(find.text('Select an EPUB file'), findsNothing); -}); -``` - -- [ ] **Step 3: Run the tests and verify both fail** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: FAIL because desktop width currently opens `Dialog` widgets and the copy is EPUB-specific. - -### Task 2: Convert the choice dialog to a bottom sheet - -**Files:** -- Modify: `app/lib/widgets/add_book/add_book_choice_sheet.dart` -- Test: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [ ] **Step 1: Use one modal bottom-sheet entry point** - -Replace the desktop/mobile branching in `AddBookChoiceSheet.show` with: - -```dart -return showModalBottomSheet( - context: context, - useRootNavigator: true, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), - ), - builder: (_) => Padding( - padding: const EdgeInsets.only( - left: Spacing.lg, - right: Spacing.lg, - top: Spacing.md, - bottom: Spacing.lg, - ), - child: AddBookChoiceSheet(callerContext: context), - ), -); -``` - -Always render `BottomSheetHandle`, remove the unused `foundation.dart` import, and set the digital option subtitle to `EPUB, PDF, AZW3, MOBI, CBZ/CBR`. - -- [ ] **Step 2: Run the focused choice-sheet test** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "add book opens as a bottom sheet on desktop" -``` - -Expected: PASS. - -### Task 3: Convert the import dialog to a content-sized bottom sheet - -**Files:** -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` -- Test: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [ ] **Step 1: Replace dialog and draggable-sheet branches** - -Use one scrollable, content-sized bottom sheet: - -```dart -return showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), - ), - builder: (_) => SingleChildScrollView( - child: const Padding( - padding: EdgeInsets.only( - left: Spacing.lg, - right: Spacing.lg, - top: Spacing.md, - bottom: Spacing.lg, - ), - child: _ImportContent(), - ), - ), -); -``` - -Always render `BottomSheetHandle`. Change the idle title to `Select a digital book file` and add `EPUB, PDF, AZW3, MOBI, CBZ/CBR` before the unchanged offline-storage explanation. Do not change `_webExtensions`, `_nativeExtensions`, or `BookImportService`. - -- [ ] **Step 2: Format and run focused verification** - -Run: - -```bash -dart format app/lib/widgets/add_book/add_book_choice_sheet.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart -flutter analyze lib/widgets/add_book/add_book_choice_sheet.dart lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: both widget tests pass and analysis reports `No issues found!`. diff --git a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md deleted file mode 100644 index fc3a3ea..0000000 --- a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md +++ /dev/null @@ -1,239 +0,0 @@ -# Book Edit Responsive Pane Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Keep the cover and edit form side by side at useful intermediate desktop widths while preventing narrow form fields and oversized stacked covers. - -**Architecture:** `BookEditPage` will use its parent constraints and minimum pane dimensions to choose between a supporting-pane row and a stacked desktop layout. `ResponsiveFormRow` will independently use its own constraints to stack paired inputs when the flexible form pane is narrow. - -**Tech Stack:** Flutter, Dart, Material widgets, `LayoutBuilder`, Flutter widget tests - ---- - -### Task 1: Capture intermediate and constrained desktop behavior - -**Files:** -- Modify: `app/test/pages/book_edit_page_layout_test.dart` - -- [ ] **Step 1: Replace the constrained-desktop test and add the intermediate case** - -Use an 800 px content allocation to assert that the cover and form remain side by side while Publisher and Language stack inside the narrower form pane. Use a 760 px allocation to assert that the page panes stack and the cover preview remains 240 px wide. - -```dart -testWidgets('intermediate desktop keeps panes side by side while paired fields stack', (tester) async { - await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 800); - - final coverHeading = tester.getTopLeft(find.text('Cover')); - final formHeading = tester.getTopLeft(find.text('Basic information')); - final publisher = tester.getTopLeft(find.text('Publisher')); - final language = tester.getTopLeft(find.text('Language')); - - expect(formHeading.dx, greaterThan(coverHeading.dx)); - expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); - expect(language.dy, greaterThan(publisher.dy)); - expect(tester.takeException(), isNull); -}); - -testWidgets('constrained desktop stacks panes and keeps the cover compact', (tester) async { - await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 760); - - final coverHeading = tester.getTopLeft(find.text('Cover')); - final formHeading = tester.getTopLeft(find.text('Basic information')); - - expect(formHeading.dy, greaterThan(coverHeading.dy)); - expect(tester.getSize(find.byType(AspectRatio).first).width, 240); - expect(tester.takeException(), isNull); -}); -``` - -- [ ] **Step 2: Run the focused tests and verify the intermediate case fails** - -Run: - -```bash -cd app -flutter test test/pages/book_edit_page_layout_test.dart -``` - -Expected: the 800 px case fails because the current 840 px page breakpoint stacks the cover above the form. - -### Task 2: Make page panes respond to usable minimum widths - -**Files:** -- Modify: `app/lib/pages/book_edit_page.dart` - -- [ ] **Step 1: Derive the pane breakpoint from layout dimensions** - -Add constants to `_BookEditPageState` and use them in `_buildDesktopLayout` and `_buildDesktopCoverPane`: - -```dart -static const double _desktopCoverPaneWidth = 280; -static const double _minimumDesktopFormPaneWidth = 420; -static const double _desktopPaneBreakpoint = - _desktopCoverPaneWidth + Spacing.xl + _minimumDesktopFormPaneWidth + (Spacing.lg * 2); -``` - -Replace the generic desktop breakpoint check: - -```dart -final showSideBySide = constraints.maxWidth >= _desktopPaneBreakpoint; -``` - -Set the cover pane width from the shared dimension: - -```dart -return SizedBox( - width: _desktopCoverPaneWidth, - child: Column(...), -); -``` - -- [ ] **Step 2: Run the layout tests** - -Run: - -```bash -cd app -flutter test test/pages/book_edit_page_layout_test.dart -``` - -Expected: the page-level position assertions pass; the intermediate paired-field assertion still fails because `ResponsiveFormRow` uses the browser-level desktop flag. - -### Task 3: Make paired form fields respond to their allocated width - -**Files:** -- Modify: `app/lib/widgets/book_form/responsive_form_row.dart` -- Test: `app/test/pages/book_edit_page_layout_test.dart` - -- [ ] **Step 1: Use local constraints for the horizontal row decision** - -Keep `isDesktop` as the mobile/desktop policy input, but use `LayoutBuilder` to require at least the existing tablet breakpoint before placing multiple fields side by side: - -```dart -@override -Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final showAsRow = - isDesktop && children.length > 1 && constraints.maxWidth >= Breakpoints.tablet; - - if (!showAsRow) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children.expand((widget) sync* { - yield widget; - yield const SizedBox(height: Spacing.md); - }).toList() - ..removeLast(), - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: children.expand((widget) sync* { - yield Expanded(child: widget); - yield const SizedBox(width: Spacing.md); - }).toList() - ..removeLast(), - ); - }, - ); -} -``` - -- [ ] **Step 2: Format and run the complete layout test file** - -Run: - -```bash -dart format app/lib/pages/book_edit_page.dart app/lib/widgets/book_form/responsive_form_row.dart app/test/pages/book_edit_page_layout_test.dart -cd app -flutter test test/pages/book_edit_page_layout_test.dart -``` - -Expected: all book edit layout tests pass with no render-overflow exceptions. - -- [ ] **Step 3: Run static analysis** - -Run: - -```bash -cd app -flutter analyze lib/pages/book_edit_page.dart lib/widgets/book_form/responsive_form_row.dart test/pages/book_edit_page_layout_test.dart -``` - -Expected: `No issues found!` - -- [ ] **Step 4: Commit the implementation** - -```bash -git add app/lib/pages/book_edit_page.dart app/lib/widgets/book_form/responsive_form_row.dart app/test/pages/book_edit_page_layout_test.dart docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md -git commit -m "fix: improve responsive book edit layout" -``` - -### Task 4: Give metadata search a clear visual hierarchy - -**Files:** -- Modify: `app/lib/pages/book_edit_page.dart` -- Test: `app/test/pages/book_edit_page_layout_test.dart` - -- [ ] **Step 1: Write the failing metadata control-order test** - -At a 640 px desktop content allocation, assert that the search field is full width, the compact source selector is below it, and the `Source` label precedes the selector. - -```dart -testWidgets('metadata search precedes a compact visible source selector', (tester) async { - await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); - - final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; - final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; - final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); - final sourceLabel = find.text('Source'); - - expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); - expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getTopLeft(searchField).dy)); - expect((tester.getCenter(selector).dy - tester.getCenter(sourceLabel).dy).abs(), lessThan(1)); -}); -``` - -- [ ] **Step 2: Run the focused test and verify it fails** - -Run: - -```bash -cd app -flutter test test/pages/book_edit_page_layout_test.dart --plain-name "metadata search precedes a compact visible source selector" -``` - -Expected: FAIL because the current source selector is beside the search field and there is no `Source` label. - -- [ ] **Step 3: Replace the inline metadata row with search-first controls** - -Keep metadata inside the desktop form pane. In `_buildMetadataSection`, render the search field first, followed by a compact source row: - -```dart -searchField, -const SizedBox(height: Spacing.md), -Row( - children: [ - Text('Source', style: Theme.of(context).textTheme.bodySmall), - const SizedBox(width: Spacing.md), - Flexible(child: sourceSelector), - ], -), -``` - -Remove `inlineControls`, `_metadataControlsRowBreakpoint`, and their `LayoutBuilder`; both desktop and mobile metadata sections use this same search-first hierarchy. - -- [ ] **Step 4: Format and verify** - -Run: - -```bash -dart format app/lib/pages/book_edit_page.dart app/test/pages/book_edit_page_layout_test.dart -cd app -flutter test test/pages/book_edit_page_layout_test.dart -flutter analyze lib/pages/book_edit_page.dart lib/widgets/book_form/responsive_form_row.dart test/pages/book_edit_page_layout_test.dart -``` - -Expected: all layout tests pass and analysis reports `No issues found!`. diff --git a/docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md b/docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md deleted file mode 100644 index dbf20eb..0000000 --- a/docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md +++ /dev/null @@ -1,133 +0,0 @@ -# Add Book Backdrop Continuity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Keep the add-book modal backdrop mounted while transitioning from the choice sheet to the digital import UI. - -**Architecture:** Make the choice sheet own a small presentation state that swaps its body from the choice options to a reusable `ImportBookSheet` widget. Keep `ImportBookSheet.show` as the direct-entry modal wrapper, but move its scrollable content into `ImportBookSheet.build` so both entry paths share the same stateful import implementation. - -**Tech Stack:** Flutter, Dart, Material modal bottom sheets, Flutter widget tests - ---- - -### Task 1: Capture modal-backdrop continuity - -**Files:** -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [x] **Step 1: Add a navigator observer and failing regression test** - -Add a `_CountingNavigatorObserver` that increments `pushCount` in `didPush`. Extend `pumpLauncher` with an optional `navigatorObservers` argument and pass it to `MaterialApp`. - -Add this test: - -```dart -testWidgets('digital import transition preserves the modal backdrop', (tester) async { - final observer = _CountingNavigatorObserver(); - await pumpLauncher( - tester, - (context) => () => AddBookChoiceSheet.show(context), - navigatorObservers: [observer], - ); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - final initialPushCount = observer.pushCount; - final initialBarrier = tester.element(find.byType(ModalBarrier)); - - await tester.tap(find.text('Import digital books')); - await tester.pumpAndSettle(); - - expect(find.text('Import book'), findsOneWidget); - expect(find.byType(ModalBarrier), findsOneWidget); - expect(tester.element(find.byType(ModalBarrier)), same(initialBarrier)); - expect(observer.pushCount, initialPushCount); -}); -``` - -- [x] **Step 2: Run the focused test and verify RED** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "digital import transition preserves the modal backdrop" -``` - -Expected: FAIL because the current callback pops the choice route and pushes a second modal route, changing the barrier element and incrementing `pushCount`. - -### Task 2: Reuse import content within the choice route - -**Files:** -- Modify: `app/lib/widgets/add_book/add_book_choice_sheet.dart` -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` -- Test: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [x] **Step 1: Make `ImportBookSheet` render its reusable content** - -Change the direct modal builder to `const ImportBookSheet()` and implement `build` as: - -```dart -@override -Widget build(BuildContext context) { - return const SingleChildScrollView(child: _ImportContent()); -} -``` - -- [x] **Step 2: Keep the choice modal route and swap its content** - -Convert `AddBookChoiceSheet` to a `StatefulWidget`. Move the choice padding into the choice-state branch so the import content retains its existing layout. Store a `_showImport` boolean and change the digital option callback to `setState(() => _showImport = true)`. - -The state build method begins with: - -```dart -@override -Widget build(BuildContext context) { - if (_showImport) { - return const ImportBookSheet(); - } - - return Padding( - padding: const EdgeInsets.only( - left: Spacing.lg, - right: Spacing.lg, - top: Spacing.md, - bottom: Spacing.lg, - ), - child: _buildChoices(context), - ); -} -``` - -Keep the physical-book callback unchanged: it still pops the choice route and opens `AddPhysicalBookSheet` with `callerContext`. - -- [x] **Step 3: Format and run the focused test to verify GREEN** - -Run: - -```bash -dart format app/lib/widgets/add_book/add_book_choice_sheet.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "digital import transition preserves the modal backdrop" -``` - -Expected: PASS. - -- [x] **Step 4: Run complete focused verification** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart -flutter analyze lib/widgets/add_book/add_book_choice_sheet.dart lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: all add-book sheet tests pass and analysis reports `No issues found!`. - -- [x] **Step 5: Commit the implementation** - -```bash -git add app/lib/widgets/add_book/add_book_choice_sheet.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md -git commit -m "fix: preserve add book modal backdrop" -``` diff --git a/docs/superpowers/plans/2026-07-14-import-action-button-shapes.md b/docs/superpowers/plans/2026-07-14-import-action-button-shapes.md deleted file mode 100644 index d058ecd..0000000 --- a/docs/superpowers/plans/2026-07-14-import-action-button-shapes.md +++ /dev/null @@ -1,155 +0,0 @@ -# Import Action Button Shapes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Render the successful digital-import action pair with matching pill-shaped buttons. - -**Architecture:** Add a test-only initial-result constructor so the real import success UI can be rendered deterministically in a widget test. Then apply an explicit `StadiumBorder` to both success actions, keeping their hierarchy, layout, and callbacks unchanged. - -**Tech Stack:** Flutter, Dart, Material buttons, Flutter widget tests - ---- - -### Task 1: Make the real success state testable - -**Files:** -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:22-59` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [x] **Step 1: Add a failing success-state rendering test** - -Import `BookImportResult` and add this fixture: - -```dart -const importedBook = BookImportResult( - bookId: 'book-1', - title: 'Frankenstein', - author: 'Mary Wollstonecraft Shelley', - pageCount: 239, - fileSize: 1024, - fileHash: 'hash', - fileExtension: 'epub', -); -``` - -Add a widget test that pumps: - -```dart -MaterialApp( - home: Scaffold( - body: ImportBookSheet.withInitialResult(importedBook), - ), -) -``` - -and expects `Pick different file` and `Add to library` to be present. - -- [x] **Step 2: Run the test to verify RED** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "successful import actions can be rendered for widget verification" -``` - -Expected: FAIL to compile because `ImportBookSheet.withInitialResult` does not exist. - -- [x] **Step 3: Add the minimal test-only initial-result seam** - -Add an `@visibleForTesting` named constructor and nullable `initialResult` field to `ImportBookSheet`. Pass it into `_ImportContent`, then initialize `_state` and `_result` in `initState`: - -```dart -const ImportBookSheet({super.key}) : initialResult = null; - -@visibleForTesting -const ImportBookSheet.withInitialResult(this.initialResult, {super.key}); - -final BookImportResult? initialResult; -``` - -```dart -@override -void initState() { - super.initState(); - _result = widget.initialResult; - _state = _result == null ? _ImportState.idle : _ImportState.success; -} -``` - -- [x] **Step 4: Run the rendering test to verify GREEN** - -Run the command from Step 2. - -Expected: PASS with both real success actions rendered. - -### Task 2: Apply and verify matching pill shapes - -**Files:** -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:335-354` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [x] **Step 1: Extend the success-state test with failing shape assertions** - -Read both rendered button widgets and resolve their explicit shape styles: - -```dart -final pickButton = tester.widget( - find.widgetWithText(OutlinedButton, 'Pick different file'), -); -final addButton = tester.widget( - find.widgetWithText(FilledButton, 'Add to library'), -); - -expect(pickButton.style?.shape?.resolve({}), isA()); -expect(addButton.style?.shape?.resolve({}), isA()); -``` - -- [x] **Step 2: Run the test to verify RED** - -Run the command from Task 1, Step 2. - -Expected: FAIL because neither success action currently defines an explicit shape style. - -- [x] **Step 3: Apply explicit pill styles to both buttons** - -Add these local styles without changing any other properties: - -```dart -style: OutlinedButton.styleFrom(shape: const StadiumBorder()), -``` - -```dart -style: FilledButton.styleFrom(shape: const StadiumBorder()), -``` - -- [x] **Step 4: Format and verify GREEN** - -Run: - -```bash -dart format app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "successful import actions can be rendered for widget verification" -``` - -Expected: PASS. - -- [x] **Step 5: Run complete focused verification** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart -flutter analyze lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: all add-book sheet tests pass and analysis reports `No issues found!`. - -- [x] **Step 6: Commit the implementation** - -```bash -git add app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart docs/superpowers/plans/2026-07-14-import-action-button-shapes.md -git commit -m "fix: unify import action button shapes" -``` diff --git a/docs/superpowers/plans/2026-07-14-import-add-loading-state.md b/docs/superpowers/plans/2026-07-14-import-add-loading-state.md deleted file mode 100644 index 1371163..0000000 --- a/docs/superpowers/plans/2026-07-14-import-add-loading-state.md +++ /dev/null @@ -1,175 +0,0 @@ -# Import Add-to-Library Loading State Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the add-to-library button color pulse with a stable, accessible `Adding...` loading state. - -**Architecture:** Extend the existing test-only successful-import constructor so widget tests can render the real committing state. Keep both buttons disabled during the commit, override only their disabled colors to match their active visuals, show progress inside the primary action, and avoid resetting the successful commit state before dismissing the sheet. - -**Tech Stack:** Flutter, Dart, Material buttons, Flutter widget tests - ---- - -### Task 1: Render the committing success state in tests - -**Files:** -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:25-75` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [x] **Step 1: Add a failing committing-state test** - -Add a widget test that pumps: - -```dart -MaterialApp( - theme: AppTheme.dark, - home: const Scaffold( - body: ImportBookSheet.withInitialResult( - importedBook, - initialCommitting: true, - ), - ), -) -``` - -Read the `Pick different file` and primary filled buttons, then expect both `onPressed` callbacks to be null. - -- [x] **Step 2: Run the test to verify RED** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "committing import actions stay visually stable and show progress" -``` - -Expected: FAIL to compile because `initialCommitting` is not defined. - -- [x] **Step 3: Add the minimal initial-committing test seam** - -Give `ImportBookSheet`, `_ImportContent`, and `_ImportContentState` an initial committing value: - -```dart -const ImportBookSheet({super.key}) - : initialResult = null, - initialCommitting = false; - -@visibleForTesting -const ImportBookSheet.withInitialResult( - this.initialResult, { - this.initialCommitting = false, - super.key, -}); - -final bool initialCommitting; -``` - -Pass the value into `_ImportContent`, declare `_committing` as `late`, and assign `widget.initialCommitting` in `initState`. - -- [x] **Step 4: Run the test to verify the seam is GREEN** - -Run the command from Step 2. - -Expected: PASS for the disabled-callback assertions. - -### Task 2: Keep colors stable and show progress - -**Files:** -- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:153-215, 349-375` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [x] **Step 1: Add failing loading-content and disabled-color assertions** - -Extend the committing-state test: - -```dart -expect(find.text('Adding...'), findsOneWidget); -expect(find.text('Add to library'), findsNothing); -expect(find.byType(CircularProgressIndicator), findsOneWidget); - -final colorScheme = Theme.of(tester.element(find.text('Adding...'))).colorScheme; -const disabled = {WidgetState.disabled}; - -expect(pickButton.style?.foregroundColor?.resolve(disabled), colorScheme.primary); -expect(pickButton.style?.side?.resolve(disabled)?.color, colorScheme.outline); -expect(addButton.style?.backgroundColor?.resolve(disabled), colorScheme.primary); -expect(addButton.style?.foregroundColor?.resolve(disabled), colorScheme.onPrimary); -``` - -- [x] **Step 2: Run the test to verify RED** - -Run the command from Task 1, Step 2. - -Expected: FAIL because the primary button still displays `Add to library` and the explicit disabled colors are absent. - -- [x] **Step 3: Implement the stable loading visuals** - -Use the success state's `colorScheme` to add: - -```dart -disabledForegroundColor: colorScheme.primary, -side: BorderSide(color: colorScheme.outline, width: BorderWidths.thin), -``` - -to the outlined button style, and: - -```dart -disabledBackgroundColor: colorScheme.primary, -disabledForegroundColor: colorScheme.onPrimary, -``` - -to the filled button style. - -When `_committing` is true, replace the primary label with: - -```dart -Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox.square( - dimension: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, - ), - ), - const SizedBox(width: Spacing.sm), - const Text('Adding...'), - ], -) -``` - -- [x] **Step 4: Preserve the loading state until successful dismissal** - -Reset `_committing` inside the error handler together with the error state. Remove the unconditional successful reset from `finally`, so a successful commit dismisses the modal while still showing `Adding...`. - -- [x] **Step 5: Format and verify GREEN** - -Run: - -```bash -dart format app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "committing import actions stay visually stable and show progress" -``` - -Expected: PASS. - -- [x] **Step 6: Run complete focused verification** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheets_test.dart -flutter analyze lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: all add-book sheet tests pass and analysis reports `No issues found!`. - -- [x] **Step 7: Commit the implementation** - -```bash -git add app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart docs/superpowers/plans/2026-07-14-import-add-loading-state.md -git commit -m "fix: stabilize import commit loading state" -``` diff --git a/docs/superpowers/plans/2026-07-24-acquisition-bottom-sheets.md b/docs/superpowers/plans/2026-07-24-acquisition-bottom-sheets.md deleted file mode 100644 index 12d9a93..0000000 --- a/docs/superpowers/plans/2026-07-24-acquisition-bottom-sheets.md +++ /dev/null @@ -1,664 +0,0 @@ -# Acquisition Bottom Sheets Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace every dialog-style acquisition overlay with a Papyrus-styled bottom sheet without changing acquisition behavior. - -**Architecture:** Keep the endpoint form in its focused widget and make its launcher use one bottom-sheet path at every width. Add a focused acquisition action-sheets module for command selection, Arr ID entry, and removal confirmation, then keep the page methods thin by delegating presentation to those helpers. - -**Tech Stack:** Flutter, Material 3, Provider-backed acquisition page, `flutter_test` - ---- - -## File Map - -- Modify `app/lib/widgets/acquisition/acquisition_endpoint_editor.dart` to remove the wide-screen dialog branch. -- Modify `app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart` to require the same sheet behavior at phone and desktop widths. -- Create `app/lib/widgets/acquisition/acquisition_action_sheets.dart` for command, ID-entry, and remove-confirmation sheets. -- Create `app/test/widgets/acquisition/acquisition_action_sheets_test.dart` for direct sheet behavior and keyboard-layout tests. -- Modify `app/lib/pages/acquisition_page.dart` to delegate all auxiliary overlays to the new sheet helpers. -- Modify `app/test/pages/acquisition_page_test.dart` to prove page triggers and API results remain wired correctly. - -### Task 1: Use the Integration Editor Sheet at Every Width - -**Files:** -- Modify: `app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart` -- Modify: `app/lib/widgets/acquisition/acquisition_endpoint_editor.dart` - -- [ ] **Step 1: Replace the wide-dialog expectation with a failing wide-sheet test** - -Replace the current `uses a constrained dialog on wide windows` test with: - -```dart -testWidgets('uses the same bottom sheet on wide windows', (tester) async { - await _setWindowSize(tester, const Size(900, 900)); - await tester.pumpWidget(const _EditorLauncher()); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - final sheet = find.byKey(const Key('acquisition-endpoint-sheet')); - expect(sheet, findsOneWidget); - expect(find.byKey(const Key('acquisition-endpoint-dialog')), findsNothing); - expect( - find.descendant( - of: sheet, - matching: find.byWidgetPredicate( - (widget) => widget is FractionallySizedBox && widget.heightFactor == .92, - ), - ), - findsOneWidget, - ); - expect(find.ancestor(of: sheet, matching: find.byType(SafeArea)), findsOneWidget); -}); -``` - -- [ ] **Step 2: Run the test and verify RED** - -Run: - -```bash -cd app -flutter test --no-pub test/widgets/acquisition/acquisition_endpoint_editor_test.dart \ - --plain-name "uses the same bottom sheet on wide windows" -``` - -Expected: FAIL because a 900-pixel window still renders `acquisition-endpoint-dialog`. - -- [ ] **Step 3: Remove the responsive dialog branch** - -Replace the body of `showAcquisitionEndpointEditor` after the `editor` assignment with one bottom-sheet return: - -```dart -return showModalBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: false, - enableDrag: false, - useSafeArea: true, - showDragHandle: false, - builder: (context) => KeyedSubtree( - key: const Key('acquisition-endpoint-sheet'), - child: AnimatedPadding( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - child: FractionallySizedBox( - heightFactor: .92, - child: editor, - ), - ), - ), -); -``` - -Delete the `MediaQuery.sizeOf(context).width < Breakpoints.tablet` condition and the `showDialog` branch. Keep all form state, validation, busy-state `PopScope`, callbacks, and keys unchanged. - -- [ ] **Step 4: Run the editor suite and verify GREEN** - -Run: - -```bash -cd app -flutter test --no-pub test/widgets/acquisition/acquisition_endpoint_editor_test.dart -``` - -Expected: all editor tests pass, including phone and desktop sheet rendering and pending-operation dismissal protection. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/widgets/acquisition/acquisition_endpoint_editor.dart \ - app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart -git commit -m "fix: use acquisition editor sheet on every screen" -``` - -### Task 2: Add Focused Acquisition Action Sheets - -**Files:** -- Create: `app/lib/widgets/acquisition/acquisition_action_sheets.dart` -- Create: `app/test/widgets/acquisition/acquisition_action_sheets_test.dart` - -- [ ] **Step 1: Write failing tests for the three sheet helpers** - -Create `app/test/widgets/acquisition/acquisition_action_sheets_test.dart` with a `MaterialApp` launcher and these behaviors: - -```dart -testWidgets('command selection uses a titled bottom sheet', (tester) async { - String? selected; - await tester.pumpWidget( - _SheetLauncher( - onOpen: (context) async { - selected = await showAcquisitionCommandSheet( - context: context, - endpointName: 'Readarr', - endpointKindLabel: 'Readarr', - commands: const ['BookSearch'], - commandLabel: (_) => 'Search books', - ); - }, - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('acquisition-command-sheet')), findsOneWidget); - expect(find.byType(Dialog), findsNothing); - expect(find.text('Readarr'), findsOneWidget); - - await tester.tap(find.text('Search books')); - await tester.pumpAndSettle(); - expect(selected, 'BookSearch'); -}); - -testWidgets('Arr IDs use a keyboard-aware form sheet', (tester) async { - List? ids; - await tester.pumpWidget( - _SheetLauncher( - onOpen: (context) async { - ids = await showAcquisitionIdsSheet( - context: context, - title: 'Search books', - ); - }, - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - final sheet = find.byKey(const Key('acquisition-arr-ids-sheet')); - expect(sheet, findsOneWidget); - expect(find.byType(AlertDialog), findsNothing); - expect(find.descendant(of: sheet, matching: find.byType(AnimatedPadding)), findsOneWidget); - - await tester.enterText(find.widgetWithText(TextField, 'IDs'), '42, invalid, 84'); - await tester.tap(find.widgetWithText(FilledButton, 'Run')); - await tester.pumpAndSettle(); - expect(ids, [42, 84]); -}); - -testWidgets('remove confirmation uses a destructive bottom sheet', (tester) async { - bool? confirmed; - await tester.pumpWidget( - _SheetLauncher( - onOpen: (context) async { - confirmed = await showAcquisitionRemoveSheet( - context: context, - endpointName: 'Prowlarr', - ); - }, - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('acquisition-remove-sheet')), findsOneWidget); - expect(find.byType(AlertDialog), findsNothing); - expect(find.text('Saved credentials for this integration will be removed.'), findsOneWidget); - - await tester.tap(find.widgetWithText(FilledButton, 'Remove')); - await tester.pumpAndSettle(); - expect(confirmed, isTrue); -}); -``` - -Define `_SheetLauncher` as a small stateless test widget that accepts `Future Function(BuildContext)` and calls it from an `Open` button. - -- [ ] **Step 2: Run the new test file and verify RED** - -Run: - -```bash -cd app -flutter test --no-pub test/widgets/acquisition/acquisition_action_sheets_test.dart -``` - -Expected: compilation fails because the action-sheet helpers do not exist. - -- [ ] **Step 3: Implement the action-sheet module** - -Create `app/lib/widgets/acquisition/acquisition_action_sheets.dart` with these public APIs: - -```dart -typedef AcquisitionCommandLabel = String Function(String command); - -Future showAcquisitionCommandSheet({ - required BuildContext context, - required String endpointName, - required String endpointKindLabel, - required List commands, - required AcquisitionCommandLabel commandLabel, -}); - -Future?> showAcquisitionIdsSheet({ - required BuildContext context, - required String title, -}); - -Future showAcquisitionRemoveSheet({ - required BuildContext context, - required String endpointName, -}); -``` - -Implement the command selector with `showModalBottomSheet`, `useSafeArea: true`, key `acquisition-command-sheet`, `BottomSheetHandle`, `BottomSheetHeader` with no save action, and the existing command `ListTile` rows: - -```dart -return showModalBottomSheet( - context: context, - useSafeArea: true, - builder: (sheetContext) => Padding( - key: const Key('acquisition-command-sheet'), - padding: const EdgeInsets.all(Spacing.md), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const BottomSheetHandle(), - const SizedBox(height: Spacing.md), - BottomSheetHeader( - title: endpointName, - onCancel: () => Navigator.pop(sheetContext), - ), - Text(endpointKindLabel), - const SizedBox(height: Spacing.sm), - for (final command in commands) - ListTile( - leading: const Icon(Icons.play_arrow_outlined), - title: Text(commandLabel(command)), - subtitle: Text(command), - onTap: () => Navigator.pop(sheetContext, command), - ), - ], - ), - ), -); -``` - -Implement the ID form with `showModalBottomSheet>`, `isScrollControlled: true`, `useSafeArea: true`, an `AnimatedPadding` using `MediaQuery.viewInsetsOf(sheetContext).bottom`, key `acquisition-arr-ids-sheet`, `BottomSheetHandle`, and `BottomSheetHeader(saveLabel: 'Run')`. Keep `enteredIds` as a local string updated by `TextField.onChanged`, and parse it with: - -```dart -final ids = enteredIds - .split(',') - .map((value) => int.tryParse(value.trim())) - .whereType() - .toList(); -Navigator.pop(sheetContext, ids); -``` - -Implement removal with `showModalBottomSheet`, `useSafeArea: true`, key `acquisition-remove-sheet`, `BottomSheetHandle`, the exact warning copy, Cancel, and a `FilledButton` styled from the current color scheme: - -```dart -FilledButton( - style: FilledButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ), - onPressed: () => Navigator.pop(sheetContext, true), - child: const Text('Remove'), -) -``` - -- [ ] **Step 4: Run the new test suite and verify GREEN** - -Run: - -```bash -cd app -flutter test --no-pub test/widgets/acquisition/acquisition_action_sheets_test.dart -``` - -Expected: all command, ID, cancellation, and removal sheet tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/widgets/acquisition/acquisition_action_sheets.dart \ - app/test/widgets/acquisition/acquisition_action_sheets_test.dart -git commit -m "feat: add acquisition action sheets" -``` - -### Task 3: Route Acquisition Page Overlays Through Sheets - -**Files:** -- Modify: `app/lib/pages/acquisition_page.dart` -- Modify: `app/test/pages/acquisition_page_test.dart` - -- [ ] **Step 1: Add failing page-level overlay tests** - -Extend `_FakeAcquisitionApiClient` with: - -```dart -final deletedEndpointIds = []; - -@override -Future deleteEndpoint({ - required String accessToken, - required String endpointId, -}) async { - deletedEndpointIds.add(endpointId); -} -``` - -In the active Arr flow test, after selecting `Run action`, assert the command and ID overlays use the new keys and no dialogs: - -```dart -expect(find.byKey(const Key('acquisition-command-sheet')), findsOneWidget); -expect(find.byType(AlertDialog), findsNothing); - -await tester.tap(find.text('Search books')); -await tester.pumpAndSettle(); - -expect(find.byKey(const Key('acquisition-arr-ids-sheet')), findsOneWidget); -expect(find.byType(AlertDialog), findsNothing); -``` - -Add a removal test: - -```dart -testWidgets('remove action confirms through a bottom sheet', (tester) async { - final apiClient = _FakeAcquisitionApiClient() - ..endpointsResult = [_indexerOne]; - - await tester.pumpWidget(await _buildPage(apiClient)); - await tester.pumpAndSettle(); - - _selectEndpointMenu(tester, _indexerOne, 'delete'); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('acquisition-remove-sheet')), findsOneWidget); - expect(find.byType(AlertDialog), findsNothing); - - await tester.tap(find.widgetWithText(FilledButton, 'Remove')); - await tester.pumpAndSettle(); - - expect(apiClient.deletedEndpointIds, ['indexer-1']); -}); -``` - -- [ ] **Step 2: Run the page tests and verify RED** - -Run: - -```bash -cd app -flutter test --no-pub test/pages/acquisition_page_test.dart -``` - -Expected: FAIL because Arr IDs and removal still use `AlertDialog`, and the expected sheet keys are absent. - -- [ ] **Step 3: Delegate the page methods to the new helpers** - -Import: - -```dart -import 'package:papyrus/widgets/acquisition/acquisition_action_sheets.dart'; -``` - -Replace `_pickArrCommand` with: - -```dart -Future _pickArrCommand( - AcquisitionEndpoint endpoint, - List commands, -) { - return showAcquisitionCommandSheet( - context: context, - endpointName: endpoint.name, - endpointKindLabel: endpoint.kind.label, - commands: commands, - commandLabel: _arrCommandLabel, - ); -} -``` - -Replace `_askForIds` with: - -```dart -Future?> _askForIds(String command) { - return showAcquisitionIdsSheet( - context: context, - title: _arrCommandLabel(command), - ); -} -``` - -Replace the confirmation creation at the start of `_deleteEndpoint` with: - -```dart -final confirmed = await showAcquisitionRemoveSheet( - context: context, - endpointName: endpoint.name, -); -``` - -Keep the `confirmed != true` guard, authenticated delete, reload, and snackbar error handling unchanged. - -- [ ] **Step 4: Run page and focused acquisition tests** - -Run: - -```bash -cd app -flutter test --no-pub \ - test/pages/acquisition_page_test.dart \ - test/widgets/acquisition/acquisition_action_sheets_test.dart \ - test/widgets/acquisition/acquisition_endpoint_editor_test.dart \ - test/widgets/acquisition/acquisition_settings_section_test.dart -``` - -Expected: all focused acquisition tests pass with no dialog-based acquisition overlays. - -- [ ] **Step 5: Commit** - -```bash -git add app/lib/pages/acquisition_page.dart \ - app/test/pages/acquisition_page_test.dart -git commit -m "fix: use sheets for acquisition actions" -``` - -### Task 4: Final Verification of the Full Flutter Suite - -**Files:** -- Verify all changed production and test files. - -- [ ] **Step 1: Check formatting** - -Run: - -```bash -cd app -dart format --output=none --set-exit-if-changed lib test -``` - -Expected: exit 0 and zero changed files. - -- [ ] **Step 2: Run the analyzer** - -Run: - -```bash -cd app -flutter analyze --no-pub -``` - -Expected: `No issues found!` - -- [ ] **Step 3: Run the focused UI suite** - -Run: - -```bash -cd app -flutter test --no-pub \ - test/pages/profile_storage_sync_test.dart \ - test/pages/acquisition_page_test.dart \ - test/widgets/acquisition/acquisition_action_sheets_test.dart \ - test/widgets/acquisition/acquisition_endpoint_editor_test.dart \ - test/widgets/acquisition/acquisition_settings_section_test.dart -``` - -Expected: all focused tests pass. - -- [ ] **Step 4: Run the complete Flutter suite** - -Run: - -```bash -cd app -flutter test --no-pub -``` - -Expected: all non-skipped tests pass. - -- [ ] **Step 5: Verify repository state** - -Run: - -```bash -git diff --check -git status --short --branch -``` - -Expected: no whitespace errors and a clean `feature/torrent-acquisition` branch containing only the planned commits. - -### Task 5: Correct the Editor to Behave as a Real Bottom Sheet - -**Files:** -- Create: `app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart` -- Create: `app/test/widgets/acquisition/guarded_bottom_sheet_route_test.dart` -- Modify: `app/lib/widgets/acquisition/acquisition_endpoint_editor.dart` -- Modify: `app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart` - -- [ ] **Step 1: Write failing sizing and idle-interaction tests** - -Replace the fixed `.92` height assertions with tests that prove: - -```dart -final sheet = find.byKey(const Key('acquisition-endpoint-sheet')); -expect(tester.getSize(sheet).height, lessThan(700)); - -final bottomSheet = tester.widget(find.byType(BottomSheet)); -expect(bottomSheet.enableDrag, isTrue); -expect(bottomSheet.showDragHandle, isTrue); -``` - -At a `900 × 900` viewport, the short Prowlarr form must be substantially shorter than the old 828-pixel fixed height. Add separate idle tests that dismiss the editor by backdrop tap, Back, and downward drag. - -- [ ] **Step 2: Run the editor tests and verify RED** - -Run: - -```bash -cd app -flutter test --no-pub test/widgets/acquisition/acquisition_endpoint_editor_test.dart -``` - -Expected: FAIL because the editor is fixed at 92 percent height and the route is permanently non-dismissible and non-draggable. - -- [ ] **Step 3: Add a live guarded bottom-sheet route** - -Create a focused route helper that owns a `ValueListenable` busy signal and subclasses `ModalBottomSheetRoute`. Override the route's `isDismissible`, `enableDrag`, and `showDragHandle` getters to return `!busy.value`, register a listener in `install()`, call `changedInternalState()` when busy changes, and remove the listener in `dispose()`. - -Expose: - -```dart -Future showGuardedModalBottomSheet({ - required BuildContext context, - required ValueListenable busy, - required WidgetBuilder builder, - required ShapeBorder shape, -}); -``` - -Construct the route with the same navigator, captured inherited themes, Material localization barrier labels, modal barrier color, safe-area behavior, and scroll-controlled behavior used by Flutter's `showModalBottomSheet`. - -Add direct route tests proving its rendered `BottomSheet` transitions from draggable/dismissible with a visible handle to locked without a handle when the notifier becomes true, and restores the idle state when false. - -- [ ] **Step 4: Make the editor content-driven with a height cap** - -Make `showAcquisitionEndpointEditor` async, create a `ValueNotifier` for the operation state, await `showGuardedModalBottomSheet`, and dispose the notifier afterward. - -Replace `FractionallySizedBox(heightFactor: .92)` with: - -```dart -final viewInsets = MediaQuery.viewInsetsOf(sheetContext); -final availableHeight = - MediaQuery.sizeOf(sheetContext).height - viewInsets.bottom; - -return AnimatedPadding( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: viewInsets.bottom), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: availableHeight * .92), - child: editor, - ), -); -``` - -Use the standard top-only `AppRadius.bottomSheet` route shape. Change the editor's root `Column` to `mainAxisSize: MainAxisSize.min` and its form body from `Expanded` to `Flexible(fit: FlexFit.loose)` so short forms hug content while long forms scroll. - -Add an `onBusyChanged` callback to `AcquisitionEndpointEditor`. Notify it whenever `_testing` or `_saving` changes. Keep the existing `PopScope`, disabled controls, local errors, successful post-frame pop, credential behavior, and callbacks. - -- [ ] **Step 5: Verify GREEN and commit** - -Run: - -```bash -cd app -flutter test --no-pub \ - test/widgets/acquisition/guarded_bottom_sheet_route_test.dart \ - test/widgets/acquisition/acquisition_endpoint_editor_test.dart -flutter analyze --no-pub \ - lib/widgets/acquisition/guarded_bottom_sheet_route.dart \ - lib/widgets/acquisition/acquisition_endpoint_editor.dart \ - test/widgets/acquisition/guarded_bottom_sheet_route_test.dart \ - test/widgets/acquisition/acquisition_endpoint_editor_test.dart -``` - -Expected: short forms hug content, idle dismissal paths work, pending operations block all dismissal paths, and all focused tests pass. - -```bash -git add app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart \ - app/lib/widgets/acquisition/acquisition_endpoint_editor.dart \ - app/test/widgets/acquisition/guarded_bottom_sheet_route_test.dart \ - app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart -git commit -m "fix: restore true acquisition sheet behavior" -``` - -### Task 6: Verify the Corrected Sheet Experience - -- [ ] **Step 1: Run formatting, analysis, focused tests, and the complete suite** - -Run: - -```bash -cd app -dart format --output=none --set-exit-if-changed lib test -flutter analyze --no-pub -flutter test --no-pub \ - test/pages/acquisition_page_test.dart \ - test/widgets/acquisition/acquisition_action_sheets_test.dart \ - test/widgets/acquisition/acquisition_endpoint_editor_test.dart \ - test/widgets/acquisition/guarded_bottom_sheet_route_test.dart \ - test/widgets/shared/bottom_sheet_header_test.dart -flutter test --no-pub -``` - -Expected: formatter and analyzer are clean and all non-skipped tests pass. - -- [ ] **Step 2: Review repository state** - -Run: - -```bash -git diff --check -git status --short --branch -``` - -Expected: no whitespace errors and a clean feature branch ready for local visual testing. diff --git a/docs/superpowers/plans/2026-07-27-reader-integration.md b/docs/superpowers/plans/2026-07-27-reader-integration.md deleted file mode 100644 index ab96019..0000000 --- a/docs/superpowers/plans/2026-07-27-reader-integration.md +++ /dev/null @@ -1,62 +0,0 @@ -# Reader Integration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Open cached EPUB and PDF books in the Papyrus reader from the book details page and persist reading position. - -**Architecture:** The client owns media access and persisted book state. A pure adapter translates client books and preferences to reader domain types, while a full-screen `ReaderPage` coordinates loading and debounced persistence. - -**Tech Stack:** Flutter, Provider, go_router, papyrus_reader, flutter_test - ---- - -### Task 1: Reader adapter - -**Files:** -- Create: `app/lib/reader/reader_book_adapter.dart` -- Test: `app/test/reader/reader_book_adapter_test.dart` - -- [ ] Write failing tests for EPUB/PDF format mapping, unsupported formats, - safe locator restoration, locator persistence, and preference mapping. -- [ ] Run `flutter test test/reader/reader_book_adapter_test.dart` and verify - that it fails because the adapter does not exist. -- [ ] Implement pure conversion and update functions. -- [ ] Re-run the test and verify it passes. - -### Task 2: Reader page - -**Files:** -- Create: `app/lib/pages/reader_page.dart` -- Create: `app/lib/reader/reader_session.dart` -- Test: `app/test/reader/reader_session_test.dart` - -- [ ] Write failing tests proving locator updates are debounced and the final - pending locator can be flushed. -- [ ] Run `flutter test test/reader/reader_session_test.dart` and verify the - expected failure. -- [ ] Implement the session coordinator and full-screen page using - `ReaderDocument`, cached client bytes, and `PapyrusReader`. -- [ ] Re-run the reader tests and verify they pass. - -### Task 3: Routing and Start reading - -**Files:** -- Modify: `app/lib/config/app_router.dart` -- Modify: `app/lib/pages/book_details_page.dart` -- Modify: `app/pubspec.yaml` -- Modify: `app/pubspec.lock` -- Modify: `app/test/config/app_router_test.dart` - -- [ ] Add a failing route test for `/library/read/:bookId`. -- [ ] Run the route test and verify the missing route failure. -- [ ] Add the sibling package dependency, full-screen route, EPUB/PDF gate, and - navigation from the existing callback. -- [ ] Run focused reader, router, and book-details tests. - -### Task 4: Verification - -- [ ] Run `dart format` on changed Dart files. -- [ ] Run all focused reader and route tests. -- [ ] Run `flutter analyze`. -- [ ] Run the full Flutter test suite. -- [ ] Inspect `git diff --check` and the final working tree. diff --git a/docs/superpowers/plans/2026-08-01-advanced-filter-sheet-sizing.md b/docs/superpowers/plans/2026-08-01-advanced-filter-sheet-sizing.md deleted file mode 100644 index 8e524d6..0000000 --- a/docs/superpowers/plans/2026-08-01-advanced-filter-sheet-sizing.md +++ /dev/null @@ -1,105 +0,0 @@ -# Advanced Filter Sheet Sizing Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make Advanced filters use the standard Papyrus bottom-sheet width and consistent 24px horizontal content insets. - -**Architecture:** Keep the change entirely inside `LibraryAdvancedFilterSheet`. Remove its modal-level custom width override so Flutter’s shared Material sheet constraints match other sheets, then update only the outer horizontal padding for the header, scrollable body, and sticky footer. - -**Tech Stack:** Flutter, Dart, Material 3, Papyrus design tokens - ---- - -### Task 1: Align advanced-filter sheet sizing and content insets - -**Files:** -- Modify: `app/lib/widgets/library/library_advanced_filter_sheet.dart:34-77` -- Modify: `app/lib/widgets/library/library_advanced_filter_sheet.dart:200-350` -- Test: none, following the established request not to add or modernize tests for this feature work - -- [ ] **Step 1: Remove the custom desktop width override** - -Delete the locally calculated 760px maximum width: - -```dart -final maxWidth = MediaQuery.sizeOf(context).width.clamp(0, 760).toDouble(); -``` - -Remove this argument from `showModalBottomSheet`: - -```dart -constraints: BoxConstraints(maxWidth: maxWidth), -``` - -Keep `useRootNavigator`, `useSafeArea`, `isScrollControlled`, the transparent background, draggable sizes, snapping, decorated surface, clipping, and border radius unchanged. - -- [ ] **Step 2: Increase all outer horizontal insets to `Spacing.lg`** - -Change the scrollable filter-body padding to: - -```dart -padding: const EdgeInsets.fromLTRB( - Spacing.lg, - Spacing.sm, - Spacing.lg, - Spacing.xl, -), -``` - -Change the header padding to: - -```dart -padding: const EdgeInsets.fromLTRB( - Spacing.lg, - Spacing.md, - Spacing.lg, - Spacing.md, -), -``` - -Change the sticky action-bar padding to: - -```dart -padding: const EdgeInsets.symmetric( - horizontal: Spacing.lg, - vertical: Spacing.md, -), -``` - -Do not modify padding internal to individual facet cards, search fields, chips, date controls, or range controls. - -- [ ] **Step 3: Format and run targeted static analysis** - -Run: - -```bash -dart format app/lib/widgets/library/library_advanced_filter_sheet.dart -flutter analyze \ - app/lib/widgets/library/library_advanced_filter_sheet.dart \ - app/lib/pages/library_page.dart -``` - -Expected: formatting succeeds and analysis reports `No issues found!`. - -- [ ] **Step 4: Verify responsive sheet behavior** - -Confirm through code inspection and the running app where available: - -- desktop width matches standard bottom sheets such as the book context menu; -- mobile still uses the available width; -- header title, body sections, and footer actions share 24px left/right edges; -- the narrower desktop sheet does not overflow facet controls or footer actions; -- dragging, snapping, scrolling, header/footer persistence, close, reset, cancel, preview count, and apply behavior are unchanged. - -- [ ] **Step 5: Check the diff and commit** - -Run: - -```bash -git diff --check -git diff -- app/lib/widgets/library/library_advanced_filter_sheet.dart -git add app/lib/widgets/library/library_advanced_filter_sheet.dart -git commit -m "PPR-25: Align advanced filter sheet sizing" -``` - -Expected: one focused production-file commit with no unrelated changes. diff --git a/docs/superpowers/plans/2026-08-01-book-import-workflow-rework.md b/docs/superpowers/plans/2026-08-01-book-import-workflow-rework.md deleted file mode 100644 index cd59901..0000000 --- a/docs/superpowers/plans/2026-08-01-book-import-workflow-rework.md +++ /dev/null @@ -1,805 +0,0 @@ -# Book Import Workflow Rework Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the single-file, in-place digital import flow with independent fixed-layout selection and results sheets that support confirmed batch imports, retry, removal, cleanup, and partial commit failures, while moving physical import actions into a fixed footer. - -**Architecture:** `AddBookChoiceSheet` returns a method choice and opens a new route only after the choice route completes. A workflow-local immutable batch item models every selected file and its processing or commit state. Three add-book sheets share a layout-only scaffold with a fixed header, expanded body, and fixed footer; the results sheet injects processing, deletion, and commit callbacks for deterministic widget tests while production callbacks use the existing import and commit services. - -**Tech Stack:** Flutter, Dart, Provider, `file_picker`, existing `BookImportService` and `BookImportCommitService`, `flutter_test`. - ---- - -## File Structure - -- Create `app/lib/widgets/add_book/book_import_batch_item.dart` — selected-file value and immutable per-row state transitions. -- Create `app/lib/widgets/add_book/add_book_sheet_scaffold.dart` — fixed handle/header, expanded body, and fixed safe-area footer. -- Create `app/lib/widgets/add_book/digital_book_import_sheet.dart` — multi-file selection, confirmation, and pre-processing removal. -- Create `app/lib/widgets/add_book/book_import_results_sheet.dart` — processing, retry, removal, cleanup, commit, and results UI. -- Modify `app/lib/widgets/add_book/add_book_choice_sheet.dart` — selection-only routing. -- Modify `app/lib/widgets/add_book/add_physical_book_sheet.dart` — shared fixed layout and footer actions. -- Delete `app/lib/widgets/add_book/import_book_sheet.dart` after all production references move. -- Create `app/test/widgets/add_book/book_import_batch_item_test.dart`. -- Create `app/test/widgets/add_book/add_book_sheet_scaffold_test.dart`. -- Create `app/test/widgets/add_book/digital_book_import_sheet_test.dart`. -- Create `app/test/widgets/add_book/book_import_results_sheet_test.dart`. -- Modify `app/test/widgets/add_book/add_book_sheets_test.dart` — method routing and physical-sheet integration. -- Modify `app/test/media/media_profile_switch_contract_test.dart` — point commit-boundary contracts at the results sheet. - -### Task 1: Model Batch Files and Row State - -**Files:** -- Create: `app/lib/widgets/add_book/book_import_batch_item.dart` -- Test: `app/test/widgets/add_book/book_import_batch_item_test.dart` - -- [ ] **Step 1: Write failing transition tests** - -```dart -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:papyrus/services/book_import_result.dart'; -import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; - -void main() { - const result = BookImportResult( - bookId: 'book-1', - title: 'Frankenstein', - author: 'Mary Shelley', - fileSize: 4, - fileHash: 'hash', - fileExtension: 'epub', - ); - - test('processing transitions preserve identity and clear stale errors', () { - final file = SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1, 2, 3, 4])); - final failed = BookImportBatchItem.queued(id: 'row-1', file: file) - .startProcessing() - .processingFailed('Could not process this file.'); - final ready = failed.startProcessing().processingSucceeded(result); - - expect(ready.id, 'row-1'); - expect(ready.status, BookImportBatchStatus.ready); - expect(ready.result, same(result)); - expect(ready.errorMessage, isNull); - }); - - test('commit failure keeps the parsed result for retry', () { - final file = SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])); - final failed = BookImportBatchItem.queued(id: 'row-1', file: file) - .startProcessing() - .processingSucceeded(result) - .startAdding() - .commitFailed('Could not add this book.'); - - expect(failed.status, BookImportBatchStatus.commitFailed); - expect(failed.result, same(result)); - expect(failed.canRetry, isTrue); - }); -} -``` - -- [ ] **Step 2: Run the model test and verify RED** - -Run: `cd app && flutter test test/widgets/add_book/book_import_batch_item_test.dart` - -Expected: compilation fails because `book_import_batch_item.dart` and its types do not exist. - -- [ ] **Step 3: Implement the immutable batch types** - -```dart -import 'dart:typed_data'; - -import 'package:flutter/foundation.dart'; -import 'package:papyrus/services/book_import_result.dart'; - -@immutable -class SelectedBookFile { - const SelectedBookFile({required this.name, required this.bytes}); - - final String name; - final Uint8List? bytes; -} - -enum BookImportBatchStatus { - queued, - processing, - ready, - processingFailed, - adding, - added, - commitFailed, -} - -@immutable -class BookImportBatchItem { - const BookImportBatchItem._({ - required this.id, - required this.file, - required this.status, - this.result, - this.errorMessage, - }); - - factory BookImportBatchItem.queued({required String id, required SelectedBookFile file}) { - return BookImportBatchItem._(id: id, file: file, status: BookImportBatchStatus.queued); - } - - final String id; - final SelectedBookFile file; - final BookImportBatchStatus status; - final BookImportResult? result; - final String? errorMessage; - - bool get canRetry => - status == BookImportBatchStatus.processingFailed || status == BookImportBatchStatus.commitFailed; - bool get isSettled => status != BookImportBatchStatus.queued && status != BookImportBatchStatus.processing; - bool get hasTemporaryFile => result != null && status != BookImportBatchStatus.added; - - BookImportBatchItem startProcessing() => BookImportBatchItem._( - id: id, - file: file, - status: BookImportBatchStatus.processing, - ); - - BookImportBatchItem processingSucceeded(BookImportResult value) => BookImportBatchItem._( - id: id, - file: file, - status: BookImportBatchStatus.ready, - result: value, - ); - - BookImportBatchItem processingFailed(String message) => BookImportBatchItem._( - id: id, - file: file, - status: BookImportBatchStatus.processingFailed, - errorMessage: message, - ); - - BookImportBatchItem startAdding() => BookImportBatchItem._( - id: id, - file: file, - status: BookImportBatchStatus.adding, - result: result, - ); - - BookImportBatchItem added() => BookImportBatchItem._( - id: id, - file: file, - status: BookImportBatchStatus.added, - result: result, - ); - - BookImportBatchItem commitFailed(String message) => BookImportBatchItem._( - id: id, - file: file, - status: BookImportBatchStatus.commitFailed, - result: result, - errorMessage: message, - ); -} -``` - -- [ ] **Step 4: Run the model test and verify GREEN** - -Run: `cd app && flutter test test/widgets/add_book/book_import_batch_item_test.dart` - -Expected: all batch-item tests pass. - -- [ ] **Step 5: Commit the model** - -```bash -git add app/lib/widgets/add_book/book_import_batch_item.dart app/test/widgets/add_book/book_import_batch_item_test.dart -git commit -m "PPR-26: Model batch book imports" -``` - -### Task 2: Add the Fixed Add-Book Sheet Layout and Migrate Physical Entry - -**Files:** -- Create: `app/lib/widgets/add_book/add_book_sheet_scaffold.dart` -- Create: `app/test/widgets/add_book/add_book_sheet_scaffold_test.dart` -- Modify: `app/lib/widgets/add_book/add_physical_book_sheet.dart` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [ ] **Step 1: Write failing fixed-layout tests** - -```dart -Future openPhysicalBookSheet(WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) => Scaffold( - body: FilledButton( - onPressed: () => AddPhysicalBookSheet.show(context), - child: const Text('Open physical import'), - ), - ), - ), - ), - ); - await tester.tap(find.text('Open physical import')); - await tester.pumpAndSettle(); -} - -testWidgets('header and footer remain fixed while the body scrolls', (tester) async { - final controller = ScrollController(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 500, - child: AddBookSheetScaffold( - title: 'Import books', - onClose: () {}, - body: ListView( - controller: controller, - children: List.generate(40, (index) => Text('Row $index')), - ), - footer: const Text('Fixed footer', key: Key('fixed-footer')), - ), - ), - ), - ), - ); - - final headerTop = tester.getTopLeft(find.text('Import books')); - final footerTop = tester.getTopLeft(find.byKey(const Key('fixed-footer'))); - await tester.drag(find.byType(ListView), const Offset(0, -600)); - await tester.pump(); - - expect(tester.getTopLeft(find.text('Import books')), headerTop); - expect(tester.getTopLeft(find.byKey(const Key('fixed-footer'))), footerTop); -}); - -testWidgets('physical Add action is rendered in the footer', (tester) async { - await openPhysicalBookSheet(tester); - - expect(find.byKey(const Key('add-book-sheet-header')), findsOneWidget); - expect(find.byKey(const Key('add-book-sheet-footer')), findsOneWidget); - expect( - find.descendant( - of: find.byKey(const Key('add-book-sheet-footer')), - matching: find.widgetWithText(FilledButton, 'Add'), - ), - findsOneWidget, - ); -}); -``` - -- [ ] **Step 2: Run the scaffold and add-book sheet tests and verify RED** - -Run: `cd app && flutter test test/widgets/add_book/add_book_sheet_scaffold_test.dart test/widgets/add_book/add_book_sheets_test.dart` - -Expected: the scaffold type and fixed-footer keys are missing, and the physical Add action is still in `BottomSheetHeader`. - -- [ ] **Step 3: Implement the shared layout** - -Create `AddBookSheetScaffold` with this public interface and structure: - -```dart -class AddBookSheetScaffold extends StatelessWidget { - const AddBookSheetScaffold({ - super.key, - required this.title, - required this.onClose, - required this.body, - required this.footer, - this.canClose = true, - }); - - final String title; - final VoidCallback onClose; - final Widget body; - final Widget footer; - final bool canClose; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Column( - children: [ - Padding( - key: const Key('add-book-sheet-header'), - padding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.md), - child: Column( - children: [ - const BottomSheetHandle(), - const SizedBox(height: Spacing.lg), - Row( - children: [ - Expanded(child: Text(title, style: Theme.of(context).textTheme.headlineSmall)), - IconButton( - icon: const Icon(Icons.close), - tooltip: 'Close', - onPressed: canClose ? onClose : null, - ), - ], - ), - ], - ), - ), - const Divider(height: 1), - Expanded(child: body), - Container( - key: const Key('add-book-sheet-footer'), - padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.md), - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(top: BorderSide(color: colorScheme.outlineVariant)), - ), - child: SafeArea(top: false, child: footer), - ), - ], - ); - } -} -``` - -- [ ] **Step 4: Move physical actions into the footer** - -Replace the physical sheet’s top `BottomSheetHeader` and trailing body structure with `AddBookSheetScaffold`. Supply the existing form `ListView` as `body` and this footer: - -```dart -Row( - children: [ - const Spacer(), - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), - const SizedBox(width: Spacing.sm), - FilledButton(onPressed: _canSave ? _onSave : null, child: const Text('Add')), - ], -) -``` - -Keep `MediaQuery.viewInsets.bottom` around the scaffold so the keyboard does not cover the footer. - -- [ ] **Step 5: Run the focused tests and verify GREEN** - -Run: `cd app && flutter test test/widgets/add_book/add_book_sheet_scaffold_test.dart test/widgets/add_book/add_book_sheets_test.dart` - -Expected: fixed-layout and physical-footer tests pass. - -- [ ] **Step 6: Commit the shared layout and physical migration** - -```bash -git add app/lib/widgets/add_book/add_book_sheet_scaffold.dart app/lib/widgets/add_book/add_physical_book_sheet.dart app/test/widgets/add_book/add_book_sheet_scaffold_test.dart app/test/widgets/add_book/add_book_sheets_test.dart -git commit -m "PPR-26: Fix physical import sheet actions" -``` - -### Task 3: Build the Confirmed Multi-File Selection Sheet - -**Files:** -- Create: `app/lib/widgets/add_book/digital_book_import_sheet.dart` -- Test: `app/test/widgets/add_book/digital_book_import_sheet_test.dart` - -- [ ] **Step 1: Write failing selection and removal tests** - -```dart -testWidgets('confirms multiple selected files and removes accidental selections', (tester) async { - final files = [ - SelectedBookFile(name: 'one.epub', bytes: Uint8List.fromList([1])), - SelectedBookFile(name: 'two.epub', bytes: Uint8List.fromList([2])), - ]; - List? confirmed; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 600, - child: DigitalBookImportSheet( - pickFiles: () async => files, - onConfirm: (value) => confirmed = value, - onCancel: () {}, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Browse files')); - await tester.pump(); - expect(find.text('one.epub'), findsOneWidget); - expect(find.text('two.epub'), findsOneWidget); - expect(find.text('Import 2 books'), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('remove-two.epub'))); - await tester.pump(); - await tester.tap(find.text('Import 1 book')); - - expect(confirmed!.map((file) => file.name), ['one.epub']); - expect(find.byKey(const Key('add-book-sheet-header')), findsOneWidget); - expect(find.byKey(const Key('add-book-sheet-footer')), findsOneWidget); -}); - -testWidgets('a fresh picker result replaces the selection and unreadable files cannot confirm alone', (tester) async { - var pickCount = 0; - List? confirmed; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 600, - child: DigitalBookImportSheet( - pickFiles: () async { - pickCount++; - return pickCount == 1 - ? [SelectedBookFile(name: 'first.epub', bytes: Uint8List.fromList([1]))] - : const [SelectedBookFile(name: 'unreadable.epub', bytes: null)]; - }, - onConfirm: (value) => confirmed = value, - onCancel: () {}, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Browse files')); - await tester.pump(); - await tester.tap(find.text('Browse files')); - await tester.pump(); - - expect(find.text('first.epub'), findsNothing); - expect(find.text('unreadable.epub'), findsOneWidget); - final button = tester.widget(find.widgetWithText(FilledButton, 'Import 1 book')); - expect(button.onPressed, isNull); - expect(confirmed, isNull); -}); -``` - -- [ ] **Step 2: Run the digital sheet test and verify RED** - -Run: `cd app && flutter test test/widgets/add_book/digital_book_import_sheet_test.dart` - -Expected: compilation fails because `DigitalBookImportSheet` does not exist. - -- [ ] **Step 3: Implement the picker adapter and sheet** - -Define: - -```dart -typedef DigitalBookFilePicker = Future> Function(); - -Future> pickDigitalBookFiles() async { - final extensions = kIsWeb ? const ['epub'] : const ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz']; - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: extensions, - allowMultiple: true, - withData: true, - ); - if (result == null) return const []; - return [for (final file in result.files) SelectedBookFile(name: file.name, bytes: file.bytes)]; -} -``` - -Give `DigitalBookImportSheet` the testable constructor used above and a production `show` method that wraps it in a `DraggableScrollableSheet`. Use `AddBookSheetScaffold`, a `ListView` body, keyed remove buttons, and a footer containing Cancel plus a pluralized `Import N book(s)` button. Replacing the selection after each non-empty picker result must be one `setState` call. - -- [ ] **Step 4: Run the selection tests and verify GREEN** - -Run: `cd app && flutter test test/widgets/add_book/digital_book_import_sheet_test.dart` - -Expected: multi-selection, replacement, removal, unreadable-file display, and confirmation tests pass. - -- [ ] **Step 5: Commit the digital selection sheet** - -```bash -git add app/lib/widgets/add_book/digital_book_import_sheet.dart app/test/widgets/add_book/digital_book_import_sheet_test.dart -git commit -m "PPR-26: Add batch digital import selection" -``` - -### Task 4: Process, Route, Retry, Remove, and Clean Batch Results - -**Files:** -- Create: `app/lib/widgets/add_book/book_import_results_sheet.dart` -- Create: `app/test/widgets/add_book/book_import_results_sheet_test.dart` -- Modify: `app/lib/widgets/add_book/add_book_choice_sheet.dart` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` - -- [ ] **Step 1: Write failing independent-result tests** - -```dart -BookImportResult importResult(String filename, {required String bookId}) { - return BookImportResult( - bookId: bookId, - title: filename, - author: 'Author', - fileSize: 1, - fileHash: 'hash-$bookId', - fileExtension: 'epub', - ); -} - -Future pumpResultsSheet( - WidgetTester tester, { - required List files, - required BookImportProcessor processBook, - required ImportedBookFileDeleter deleteBookFile, - ImportedBookCommitter? commitBook, -}) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 700, - child: BookImportResultsSheet( - files: files, - processBook: processBook, - deleteBookFile: deleteBookFile, - commitBook: commitBook ?? - (result, _) async => Book( - id: result.bookId, - title: result.title, - author: result.author, - addedAt: DateTime(2026), - ), - onClose: () {}, - onCompleted: (_) {}, - ), - ), - ), - ), - ); -} - -testWidgets('processes rows independently and retries only the failed row', (tester) async { - var failingAttempts = 0; - final files = [ - SelectedBookFile(name: 'good.epub', bytes: Uint8List.fromList([1])), - SelectedBookFile(name: 'bad.epub', bytes: Uint8List.fromList([2])), - ]; - - Future process(Uint8List bytes, String filename) async { - if (filename == 'bad.epub' && failingAttempts++ == 0) throw StateError('broken'); - return importResult(filename, bookId: filename); - } - - final deleted = []; - await pumpResultsSheet( - tester, - files: files, - processBook: process, - deleteBookFile: (bookId) async => deleted.add(bookId), - ); - await tester.pumpAndSettle(); - - expect(find.text('Ready'), findsOneWidget); - expect(find.text('Failed'), findsOneWidget); - expect(find.byKey(const Key('add-book-sheet-header')), findsOneWidget); - expect(find.byKey(const Key('add-book-sheet-footer')), findsOneWidget); - await tester.tap(find.byKey(const ValueKey('retry-bad.epub'))); - await tester.pumpAndSettle(); - expect(find.text('Ready'), findsNWidgets(2)); - - await tester.tap(find.byKey(const ValueKey('remove-good.epub'))); - await tester.pump(); - expect(deleted, contains('good.epub')); -}); - -testWidgets('digital import dismisses the method sheet before opening its own route', (tester) async { - final observer = CountingNavigatorObserver(); - await pumpLauncher(tester, (context) => () => AddBookChoiceSheet.show(context), navigatorObservers: [observer]); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - final barrier = find.byWidgetPredicate((widget) => widget is ModalBarrier && widget.color != null); - final firstBarrier = tester.element(barrier); - final pushesBeforeChoice = observer.pushCount; - - await tester.tap(find.text('Import digital books')); - await tester.pumpAndSettle(); - - expect(find.text('Import digital books'), findsWidgets); - expect(observer.pushCount, pushesBeforeChoice + 1); - expect(tester.element(barrier), isNot(same(firstBarrier))); -}); -``` - -- [ ] **Step 2: Run the results test and verify RED** - -Run: `cd app && flutter test test/widgets/add_book/book_import_results_sheet_test.dart` - -Expected: the results sheet, callback typedefs, and status rows are missing. - -- [ ] **Step 3: Implement processing and row actions** - -Define these injectable callbacks: - -```dart -typedef BookImportProcessor = Future Function(Uint8List bytes, String filename); -typedef ImportedBookFileDeleter = Future Function(String bookId); -typedef ImportedBookCommitter = Future Function(BookImportResult result, String sourceFilename); -``` - -`BookImportResultsSheet.show` must resolve `BookImportService` from the caller’s provider and pass `importBook` and `deleteBookFile` into the sheet. In `initState`, create queued items with stable IDs and schedule `_processAll`. `_processItem` must: - -1. mark only that row processing; -2. turn null bytes into a user-safe processing failure; -3. await the injected processor; -4. delete a late successful result immediately when the sheet has started closing; -5. otherwise mark the row ready or failed. - -Use `AddBookSheetScaffold`, `PopScope`, a `ListView.separated`, and keyed Retry/Remove controls. `_removeItem` must await temporary-file deletion before removing a ready or commit-failed row. `_requestClose` must mark the sheet closing, clean every uncommitted result, allow pop, and then pop exactly once. - -Remove `_showImport` from `AddBookChoiceSheet`, add `_AddBookChoice.importDigital`, and route after the method sheet has completed: - -```dart -case _AddBookChoice.importDigital: - final files = await DigitalBookImportSheet.show(context); - if (!context.mounted || files == null || files.isEmpty) return; - await BookImportResultsSheet.show(context, files: files); -case _AddBookChoice.addPhysical: - await AddPhysicalBookSheet.show(context); -case _AddBookChoice.findOnline: - onFindOnline?.call(); -``` - -- [ ] **Step 4: Run processing, retry, removal, and cleanup tests and verify GREEN** - -Run: `cd app && flutter test test/widgets/add_book/book_import_results_sheet_test.dart` - -Expected: independent state, processing retry, row removal, late-result cleanup, and close cleanup tests pass. - -- [ ] **Step 5: Run the method-routing test and verify GREEN** - -Run: `cd app && flutter test test/widgets/add_book/add_book_sheets_test.dart` - -Expected: digital and physical options both dismiss the method sheet and open distinct routes. - -- [ ] **Step 6: Commit routing and result processing** - -```bash -git add app/lib/widgets/add_book/add_book_choice_sheet.dart app/lib/widgets/add_book/book_import_results_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart app/test/widgets/add_book/book_import_results_sheet_test.dart -git commit -m "PPR-26: Add batch import results" -``` - -### Task 5: Commit Ready Books and Preserve Partial Failures - -**Files:** -- Modify: `app/lib/widgets/add_book/book_import_results_sheet.dart` -- Modify: `app/test/widgets/add_book/book_import_results_sheet_test.dart` -- Modify: `app/test/media/media_profile_switch_contract_test.dart` - -- [ ] **Step 1: Write failing batch-commit tests** - -```dart -testWidgets('partial commit failure never recommits successful rows', (tester) async { - final commits = []; - var secondAttempts = 0; - final results = { - 'one.epub': importResult('One', bookId: 'one'), - 'two.epub': importResult('Two', bookId: 'two'), - }; - await pumpResultsSheet( - tester, - files: [ - SelectedBookFile(name: 'one.epub', bytes: Uint8List.fromList([1])), - SelectedBookFile(name: 'two.epub', bytes: Uint8List.fromList([2])), - ], - processBook: (_, filename) async => results[filename]!, - deleteBookFile: (_) async {}, - commitBook: (result, _) async { - commits.add(result.bookId); - if (result.bookId == 'two' && secondAttempts++ == 0) throw StateError('commit failed'); - return Book(id: result.bookId, title: result.title, author: result.author, addedAt: DateTime(2026)); - }, - ); - - await tester.tap(find.text('Add 2 to library')); - await tester.pumpAndSettle(); - expect(commits, ['one', 'two']); - expect(find.text('Added'), findsOneWidget); - expect(find.text('Failed'), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('retry-two.epub'))); - await tester.pumpAndSettle(); - expect(commits, ['one', 'two', 'two']); -}); -``` - -- [ ] **Step 2: Run the commit tests and verify RED** - -Run: `cd app && flutter test test/widgets/add_book/book_import_results_sheet_test.dart --plain-name "partial commit failure never recommits successful rows"` - -Expected: the footer does not commit multiple ready rows or preserve per-row commit state. - -- [ ] **Step 3: Move the production commit boundary into the results sheet** - -Create `_commitResult(BookImportResult result, String sourceFilename)` by moving the current dependency resolution and `BookImportCommitService.commit` setup out of `ImportBookSheet._addToLibrary`. Preserve: - -- repository capture through `requireBookRepository()`; -- account scope validation; -- pending and guest cover callbacks; -- repository add/delete compensation callbacks; -- upload queue callback; -- library-context validation; -- web OPFS and native local-file path behavior. - -Implement `_addReadyBooks` so it snapshots only ready row IDs, marks them adding, commits each once, and updates each row to added or commit-failed. Disable close, remove, retry, and footer actions while any row is adding. A commit retry calls the committer only for that row. Close with an `Added N books to library` snackbar only when no retained ready, processing-failed, or commit-failed rows remain. - -- [ ] **Step 4: Update the source contract to the new commit boundary** - -Change `media_profile_switch_contract_test.dart` to read `lib/widgets/add_book/book_import_results_sheet.dart`, extract `_commitResult`, and retain its existing assertions for account scope, cover persistence, queueing, repository identity, and context validation. Replace the old single `_committing` source assertions with widget tests that prove actions are disabled during injected commit futures. - -- [ ] **Step 5: Run commit, service, and contract tests and verify GREEN** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/book_import_results_sheet_test.dart test/services/book_import_commit_service_test.dart test/media/media_profile_switch_contract_test.dart -``` - -Expected: batch commits, partial failure retry, no duplicate additions, and the existing media-profile safety contracts pass. - -- [ ] **Step 6: Commit batch finalization** - -```bash -git add app/lib/widgets/add_book/book_import_results_sheet.dart app/test/widgets/add_book/book_import_results_sheet_test.dart app/test/media/media_profile_switch_contract_test.dart -git commit -m "PPR-26: Commit batch book imports" -``` - -### Task 6: Remove the Legacy Combined Sheet and Verify the Feature - -**Files:** -- Delete: `app/lib/widgets/add_book/import_book_sheet.dart` -- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` -- Inspect: all `app/lib` and `app/test` Dart files for stale imports and symbols. - -- [ ] **Step 1: Run the focused tests before deleting the legacy sheet** - -Run: - -```bash -cd app -flutter test test/widgets/add_book/add_book_sheet_scaffold_test.dart test/widgets/add_book/digital_book_import_sheet_test.dart test/widgets/add_book/book_import_results_sheet_test.dart test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: the new workflow passes while the unused legacy file still exists. - -- [ ] **Step 2: Delete the old sheet and remove stale references** - -Delete `app/lib/widgets/add_book/import_book_sheet.dart`. Run: - -```bash -rg -n "ImportBookSheet|import_book_sheet|_showImport" app/lib app/test -``` - -Expected: no matches. Update any remaining import or source-contract path to the new digital or results component rather than retaining compatibility aliases. - -- [ ] **Step 3: Format and analyze** - -Run: - -```bash -cd app -dart format --set-exit-if-changed lib test -flutter analyze --no-fatal-warnings --no-fatal-infos -``` - -Expected: formatting makes no changes and analysis reports no issues. - -- [ ] **Step 4: Run the complete test suite** - -Run: `cd app && flutter test --reporter expanded` - -Expected: every test passes; intentional skips remain skipped. - -- [ ] **Step 5: Review the final diff** - -Run: - -```bash -git diff --check -git status --short -git diff --stat HEAD~6..HEAD -``` - -Expected: no whitespace errors, only PPR-26 import workflow files are changed, and no generated file is present. - -- [ ] **Step 6: Commit final cleanup** - -```bash -git add -A app/lib/widgets/add_book app/test/widgets/add_book app/test/media/media_profile_switch_contract_test.dart -git commit -m "PPR-26: Remove legacy book import sheet" -``` diff --git a/docs/superpowers/plans/2026-08-01-reusable-shelf-books-page.md b/docs/superpowers/plans/2026-08-01-reusable-shelf-books-page.md deleted file mode 100644 index 7575b2a..0000000 --- a/docs/superpowers/plans/2026-08-01-reusable-shelf-books-page.md +++ /dev/null @@ -1,754 +0,0 @@ -# Reusable Shelf Books Page Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Render direct shelf members through the Books page implementation with shelf-local controls, shelf-scoped filter options, an editable shelf identity header, and a no-op `Add to shelf` action. - -**Architecture:** `ShelfContentsPage` becomes a route adapter that owns a local `LibraryProvider` and passes the current `Shelf` into a configurable `LibraryPage`. `LibraryPage` chooses either all library books or direct shelf members as its stable source collection, then reuses the existing filter, sort, selection, grid, list, and responsive presentation pipeline. Quick-filter and advanced-filter options are derived from that unfiltered source collection. - -**Tech Stack:** Flutter, Dart, Provider, GoRouter, existing Papyrus `DataStore`, `LibraryProvider`, and Material bottom sheets. - -**Repository note:** Preserve the existing uncommitted change in `app/lib/widgets/shelves/shelves_filter_chips.dart`. Stage only the files named by each task. Per the approved design, do not add or modernize automated tests. - ---- - -### Task 1: Derive Filter Options from a Supplied Book Collection - -**Files:** -- Modify: `app/lib/models/library_filter_options.dart` - -- [ ] **Step 1: Add the book model dependency and scoped source parameter** - -Import `Book` and `LibraryReadingStatus`, add scoped reading option fields, and change the factory to accept an optional source while preserving main-library behavior: - -```dart -import 'package:papyrus/models/book.dart'; -import 'package:papyrus/providers/enums/library_reading_status.dart'; - -final List> readingStatuses; -final List ratings; -final bool hasUnrated; - -factory LibraryFilterOptions.fromDataStore( - DataStore dataStore, { - Iterable? books, -}) { - final isScoped = books != null; - final sourceBooks = books ?? dataStore.books; -``` - -Add the three fields to the const constructor. - -- [ ] **Step 2: Collect organization membership from source books** - -Alongside the existing normalized metadata maps, collect stable IDs while iterating the source: - -```dart -final topicIds = {}; -final shelfIds = {}; -final readingStatuses = {}; -final ratings = {}; -var hasUnrated = false; - -for (final book in sourceBooks) { - for (final author in [book.author, ...book.coAuthors]) { - _addNormalized(authors, author); - } - - final language = book.language; - final normalizedLanguage = normalizeBookLanguage(language); - if (language != null && normalizedLanguage != null) { - languages.putIfAbsent(normalizedLanguage, () => bookLanguageLabel(language)); - } - - _addNormalized(formats, book.formatLabel); - _addNormalized(publishers, book.publisher); - _addNormalized(series, book.seriesName); - topicIds.addAll(dataStore.getTagIdsForBook(book.id)); - shelfIds.addAll(dataStore.getShelfIdsForBook(book.id)); - readingStatuses.add(book.readingStatus); - final rating = book.rating; - if (rating == null) { - hasUnrated = true; - } else { - ratings.add(rating); - } -} -``` - -Build topic and shelf options only from matching IDs, retaining the existing alphabetical sort: - -```dart -topics: _sortedOptions( - dataStore.tags - .where((topic) => topicIds.contains(topic.id)) - .map((topic) => LibraryFilterOption(value: topic.id, label: topic.name)), -), -shelves: _sortedOptions( - dataStore.shelves - .where((shelf) => shelfIds.contains(shelf.id)) - .map((shelf) => LibraryFilterOption(value: shelf.id, label: shelf.name)), -), -``` - -Populate reading choices from the source only when a scoped collection was supplied; retain the main Books page's current complete choice set otherwise: - -```dart -readingStatuses: [ - for (final status in LibraryReadingStatus.values) - if (!isScoped || readingStatuses.contains(status)) - LibraryFilterOption(value: status, label: status.label), -], -ratings: isScoped ? (ratings.toList()..sort()) : const [1, 2, 3, 4, 5], -hasUnrated: isScoped ? hasUnrated : true, -``` - -- [ ] **Step 3: Format and analyze the model** - -Run: - -```bash -cd app -dart format lib/models/library_filter_options.dart -flutter analyze lib/models/library_filter_options.dart -``` - -Expected: formatting succeeds and analysis reports no issues. - -- [ ] **Step 4: Commit the scoped option model** - -```bash -git add app/lib/models/library_filter_options.dart -git commit -m "PPR-25: Scope library filter options" -``` - -### Task 2: Pass Scoped Options and Books into Both Filter Surfaces - -**Files:** -- Modify: `app/lib/widgets/library/library_filter_chips.dart` -- Modify: `app/lib/widgets/library/library_advanced_filter_sheet.dart` - -- [ ] **Step 1: Allow quick-filter options to be supplied explicitly** - -Add an optional `LibraryFilterOptions filterOptions` field to `LibraryFilterChips` so the existing call sites remain valid until the page supplies the scoped value: - -```dart -final LibraryFilterOptions? filterOptions; - -const LibraryFilterChips({ - super.key, - this.filterOptions, - this.horizontalPadding, - this.showDownloading = false, - this.isDownloadingSelected = false, - this.onDownloadingTapped, - this.onLibraryFilterTapped, -}); -``` - -In `build`, retain the `DataStore` watch for the compatibility fallback and replace the locally created options with: - -```dart -final filterOptions = this.filterOptions ?? LibraryFilterOptions.fromDataStore(dataStore); -``` - -Replace the static status option list at use time with options derived from `filterOptions.readingStatuses`: - -```dart -final statusOptions = [ - for (final option in filterOptions.readingStatuses) - _SelectionOption( - value: option.value, - label: option.label, - icon: option.value.icon, - ), -]; -``` - -Use `statusOptions` for the status chip label and selection sheet. The main page still receives all status values through the fallback. - -- [ ] **Step 2: Add source books and options to the advanced sheet API** - -Add optional immutable inputs so the main page remains valid before Task 5 wires its explicit collection: - -```dart -final List? sourceBooks; -final LibraryFilterOptions? filterOptions; -``` - -Accept them in both `LibraryAdvancedFilterSheet.show` and its constructor, and pass them through the `DraggableScrollableSheet` builder. - -- [ ] **Step 3: Use the scoped inputs for facets and preview count** - -Replace the state initializer and preview source: - -```dart -late final List _sourceBooks = widget.sourceBooks ?? widget.dataStore.books; -late final LibraryFilterOptions _options = - widget.filterOptions ?? LibraryFilterOptions.fromDataStore(widget.dataStore, books: _sourceBooks); - -int get _matchingBookCount { - return widget.libraryProvider - .filterBooks(_sourceBooks, dataStore: widget.dataStore, filters: _draft) - .length; -} -``` - -Use `_options.readingStatuses` in the Reading status `_SmallFacet`. Extend `_RatingFilterField` with explicit availability: - -```dart -final List availableRatings; -final bool showUnrated; - -const _RatingFilterField({ - required this.ratings, - required this.includeUnrated, - required this.availableRatings, - required this.showUnrated, - required this.onChanged, -}); -``` - -Render the Unrated chip only when `showUnrated`, and iterate `availableRatings` instead of the hard-coded 1–5 loop. Only add the status and rating controls when their scoped option collections are non-empty; the main-library fallback keeps the existing controls unchanged. - -This keeps options stable while the local draft changes. - -- [ ] **Step 4: Format and analyze both filter surfaces** - -Run: - -```bash -cd app -dart format lib/widgets/library/library_filter_chips.dart lib/widgets/library/library_advanced_filter_sheet.dart -flutter analyze lib/widgets/library/library_filter_chips.dart lib/widgets/library/library_advanced_filter_sheet.dart -``` - -Expected: no analysis issues and the existing main-library call sites remain valid. - -- [ ] **Step 5: Commit the filter-surface interfaces** - -```bash -git add app/lib/widgets/library/library_filter_chips.dart app/lib/widgets/library/library_advanced_filter_sheet.dart -git commit -m "PPR-25: Add scoped library filter inputs" -``` - -### Task 3: Keep Favorite Data Shared While Shelf Controls Stay Local - -**Files:** -- Modify: `app/lib/providers/library_provider.dart` - -- [ ] **Step 1: Add an optional favorite-state delegate** - -Give a shelf-local provider access to the session's existing favorite overrides without sharing its filters, sort, view, search, or selection state: - -```dart -class LibraryProvider extends ChangeNotifier { - final LibraryProvider? _favoriteDelegate; - - LibraryProvider({LibraryProvider? favoriteDelegate}) - : _favoriteDelegate = favoriteDelegate { - _favoriteDelegate?.addListener(_onFavoriteDelegateChanged); - } - - void _onFavoriteDelegateChanged() { - notifyListeners(); - } -``` - -- [ ] **Step 2: Route favorite reads and writes through the delegate** - -Update only the favorite API: - -```dart -bool isBookFavorite(String bookId, bool originalFavorite) { - return _favoriteDelegate?.isBookFavorite(bookId, originalFavorite) ?? - _favoriteOverrides[bookId] ?? - originalFavorite; -} - -void toggleFavorite(String bookId, bool currentFavorite) { - final delegate = _favoriteDelegate; - if (delegate != null) { - delegate.toggleFavorite(bookId, currentFavorite); - return; - } - - _favoriteOverrides[bookId] = !currentFavorite; - notifyListeners(); -} - -bool? getFavoriteOverride(String bookId) { - return _favoriteDelegate?.getFavoriteOverride(bookId) ?? _favoriteOverrides[bookId]; -} -``` - -- [ ] **Step 3: Remove the delegate listener during disposal** - -```dart -@override -void dispose() { - _favoriteDelegate?.removeListener(_onFavoriteDelegateChanged); - super.dispose(); -} -``` - -- [ ] **Step 4: Format, analyze, and commit** - -```bash -cd app -dart format lib/providers/library_provider.dart -flutter analyze lib/providers/library_provider.dart -cd .. -git add app/lib/providers/library_provider.dart -git commit -m "PPR-25: Share favorite state with shelf views" -``` - -### Task 4: Support Clearing Shelf Descriptions - -**Files:** -- Modify: `app/lib/models/shelf.dart` -- Modify: `app/lib/providers/shelves_provider.dart` - -- [ ] **Step 1: Add explicit nullable-field clearing to `Shelf.copyWith`** - -Add a focused flag without changing the behavior of omitted fields: - -```dart -Shelf copyWith({ - String? id, - String? name, - String? description, - bool clearDescription = false, - String? colorHex, - IconData? icon, - String? parentShelfId, - bool? isSmart, - String? smartQuery, - int? sortOrder, - DateTime? createdAt, - DateTime? updatedAt, - int? bookCount, - List? coverPreviews, -}) { - return Shelf( - id: id ?? this.id, - name: name ?? this.name, - description: clearDescription ? null : description ?? this.description, - colorHex: colorHex ?? this.colorHex, - icon: icon ?? this.icon, - parentShelfId: parentShelfId ?? this.parentShelfId, - isSmart: isSmart ?? this.isSmart, - smartQuery: smartQuery ?? this.smartQuery, - sortOrder: sortOrder ?? this.sortOrder, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - bookCount: bookCount ?? this.bookCount, - coverPreviews: coverPreviews ?? this.coverPreviews, - ); -} -``` - -- [ ] **Step 2: Make the existing full edit flow clear an empty description** - -The current `AddShelfSheet` already returns `null` for an empty description. Update `ShelvesProvider.updateShelf`: - -```dart -final updatedShelf = shelf.copyWith( - name: name, - description: description, - clearDescription: description == null, - colorHex: colorHex, - icon: icon, - updatedAt: DateTime.now(), -); -``` - -- [ ] **Step 3: Format and analyze the update path** - -Run: - -```bash -cd app -dart format lib/models/shelf.dart lib/providers/shelves_provider.dart -flutter analyze lib/models/shelf.dart lib/providers/shelves_provider.dart lib/widgets/shelves/add_shelf_sheet.dart -``` - -Expected: no analysis issues. - -- [ ] **Step 4: Commit nullable description support** - -```bash -git add app/lib/models/shelf.dart app/lib/providers/shelves_provider.dart -git commit -m "PPR-25: Allow clearing shelf descriptions" -``` - -### Task 5: Configure `LibraryPage` for a Shelf Collection - -**Files:** -- Modify: `app/lib/pages/library_page.dart` - -- [ ] **Step 1: Add shelf presentation inputs without changing the default route** - -Import `LibraryFilterOptions` and `Shelf`, then extend the widget: - -```dart -class LibraryPage extends StatefulWidget { - final Shelf? shelf; - final VoidCallback? onBack; - final VoidCallback? onEditShelf; - - const LibraryPage({ - super.key, - this.shelf, - this.onBack, - this.onEditShelf, - }); - - bool get isShelfView => shelf != null; -``` - -The existing `const LibraryPage()` construction remains the main Books page. - -- [ ] **Step 2: Disable library-wide acquisition state in shelf mode** - -In `didChangeDependencies`, treat the downloads provider as unavailable when `widget.isShelfView`. Do the same in `build` so shelf mode cannot enter online presentation, register library visibility, expose orphan acquisition jobs, or show the downloading filter. - -Use the scoped source in `build`: - -```dart -List _sourceBooks(DataStore dataStore) { - final shelf = widget.shelf; - return shelf == null ? dataStore.books : dataStore.getBooksInShelf(shelf.id); -} - -final sourceBooks = _sourceBooks(dataStore); -final filterOptions = LibraryFilterOptions.fromDataStore( - dataStore, - books: sourceBooks, -); -final books = _getFilteredBooks(libraryProvider, dataStore, sourceBooks); -``` - -Update `_getFilteredBooks` to filter and sort the supplied source instead of reading `dataStore.books` internally: - -```dart -List _getFilteredBooks( - LibraryProvider provider, - DataStore dataStore, - List sourceBooks, -) { - final books = provider.filterBooks(sourceBooks, dataStore: dataStore); - return provider.sortBooks(books); -} -``` - -Pass `sourceBooks` and `filterOptions` into both responsive layout methods. Use `sourceBooks` when building acquisition-library items; in shelf mode the downloads provider is null, so no linked or orphan acquisition jobs are introduced. - -- [ ] **Step 3: Feed one scoped source into chips and advanced filters** - -Pass `filterOptions` into both `LibraryFilterChips` instances. Update `_showAdvancedFilters` to recompute the unfiltered current source and call: - -```dart -final dataStore = context.read(); -final sourceBooks = _sourceBooks(dataStore); - -LibraryAdvancedFilterSheet.show( - context, - libraryProvider: libraryProvider, - dataStore: dataStore, - sourceBooks: sourceBooks, - filterOptions: LibraryFilterOptions.fromDataStore(dataStore, books: sourceBooks), -); -``` - -The main page supplies all books through the same path. - -- [ ] **Step 4: Build the flat shelf identity header** - -Add a private helper that uses the shelf's icon and color without a card or shadow: - -```dart -Widget _buildShelfIdentity(BuildContext context, {required bool showBack}) { - final shelf = widget.shelf!; - final colorScheme = Theme.of(context).colorScheme; - final description = shelf.description?.trim(); - - return Row( - children: [ - if (showBack) - IconButton( - onPressed: widget.onBack, - icon: const Icon(Icons.arrow_back), - tooltip: 'Back to shelves', - ), - Icon(shelf.displayIcon, color: shelf.color ?? colorScheme.primary), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(shelf.name, maxLines: 1, overflow: TextOverflow.ellipsis), - Text( - description == null || description.isEmpty ? 'Add a description' : description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - IconButton( - onPressed: widget.onEditShelf, - icon: const Icon(Icons.edit_outlined), - tooltip: 'Edit shelf', - ), - ], - ); -} -``` - -Apply the existing typography and `onSurfaceVariant` color to the description/placeholder. On mobile render this row above a full-width search field; on desktop render it above the existing search/action row. - -- [ ] **Step 5: Replace the primary action in shelf mode** - -Keep main-library callbacks unchanged. In shelf mode: - -- desktop label: `Add to shelf`; -- mobile FAB: plus icon with tooltip `Add to shelf`; -- callback: `() {}` so the controls remain enabled and intentionally do nothing. - -Do not expose `AddBookChoiceSheet` or online search from shelf mode. - -- [ ] **Step 6: Scope the drawer, downloads chip, content, and empty states** - -- Keep `LibraryDrawer` only for the main mobile Books page; shelf mode uses its Back action. -- Build acquisition items from the scoped source only on the main page. -- Hide the downloading chip in shelf mode. -- Keep normal selection and bulk book actions for shelf books. -- If the unfiltered shelf source is empty, show `No books in this shelf`, explanatory shelf copy, and the no-op `Add to shelf` action. -- If the source is non-empty but filtering produces no books, use the normal no-results state without `Search online`. - -Carry `sourceBooks.length` through `_buildMobileLayout` / `_buildDesktopLayout` into `_buildBookContent`, and then into `_buildEmptyState`, so the empty-state decision uses the unfiltered collection count rather than the filtered result count. - -- [ ] **Step 7: Format and run targeted page analysis** - -Run: - -```bash -cd app -dart format lib/pages/library_page.dart -flutter analyze \ - lib/pages/library_page.dart \ - lib/models/library_filter_options.dart \ - lib/widgets/library/library_filter_chips.dart \ - lib/widgets/library/library_advanced_filter_sheet.dart -``` - -Expected: no analysis issues. - -- [ ] **Step 8: Commit the reusable Books page** - -```bash -git add \ - app/lib/pages/library_page.dart \ - app/lib/models/library_filter_options.dart \ - app/lib/widgets/library/library_filter_chips.dart \ - app/lib/widgets/library/library_advanced_filter_sheet.dart -git commit -m "PPR-25: Reuse books page for collections" -``` - -### Task 6: Replace `ShelfContentsPage` with a Thin Adapter - -**Files:** -- Modify: `app/lib/pages/shelf_contents_page.dart` - -- [ ] **Step 1: Replace the shelf-specific presentation with a stateless route adapter** - -Delete the separate `ShelvesProvider`, scaffold key, responsive layouts, search/sort/view controls, mixed child-shelf content, and duplicated grid/list builders. The route only retains its nullable `shelfId`: - -```dart -class ShelfContentsPage extends StatelessWidget { - final String? shelfId; - - const ShelfContentsPage({super.key, required this.shelfId}); -} -``` - -- [ ] **Step 2: Preserve the missing-shelf state** - -Watch `DataStore`, resolve `getShelf(shelfId ?? '')`, and return the existing `Shelf not found` scaffold when null. Keep its Back-to-shelves action. - -- [ ] **Step 3: Open the shared edit sheet and persist all fields** - -Add: - -```dart -void _editShelf(BuildContext context, DataStore dataStore, Shelf shelf) { - AddShelfSheet.show( - context, - shelf: shelf, - onSave: (name, description, colorHex, icon) { - dataStore.updateShelf( - shelf.copyWith( - name: name, - description: description, - clearDescription: description == null, - colorHex: colorHex, - icon: icon, - updatedAt: DateTime.now(), - ), - ); - }, - ); -} -``` - -- [ ] **Step 4: Delegate the valid shelf route to `LibraryPage`** - -Read the global `LibraryProvider` before introducing the local override. Key the local provider by shelf ID so navigating between shelf routes cannot retain controls from the previous shelf. Use `create` so Provider owns disposal: - -```dart -final favoriteState = context.read(); - -return ChangeNotifierProvider( - key: ValueKey('shelf-library-${shelf.id}'), - create: (_) => LibraryProvider(favoriteDelegate: favoriteState), - child: LibraryPage( - shelf: shelf, - onBack: () => context.go('/library/shelves'), - onEditShelf: () => _editShelf(context, dataStore, shelf), - ), -); -``` - -Do not query `getChildShelves`; hierarchy remains available elsewhere but is absent from this page. - -- [ ] **Step 5: Format and analyze the adapter** - -Run: - -```bash -cd app -dart format lib/pages/shelf_contents_page.dart -flutter analyze lib/pages/shelf_contents_page.dart lib/pages/library_page.dart lib/models/shelf.dart -``` - -Expected: no analysis issues. - -- [ ] **Step 6: Commit the route adapter** - -```bash -git add app/lib/pages/shelf_contents_page.dart -git commit -m "PPR-25: Delegate shelf books to library page" -``` - -### Task 7: Remove Obsolete Shelf-Book State - -**Files:** -- Modify: `app/lib/providers/shelves_provider.dart` - -- [ ] **Step 1: Confirm the legacy symbols have no remaining consumers** - -Run: - -```bash -rg -n "BookSortOption|BookFilterType|bookSearchQuery|isBookGridView|isBookListView|setBookViewMode|getFilteredBooksForShelf|getBooksForShelf|sortBooks\(" app/lib --glob '*.dart' -``` - -Expected: matches occur only in `shelves_provider.dart`. - -- [ ] **Step 2: Remove the duplicated shelf-book presentation model** - -Delete: - -- `BookSortOption` and `BookFilterType`; -- `_isBookGridView`, `_bookSortOption`, `_bookSortAscending`, `_bookSearchQuery`, and `_activeBookFilters`; -- their getters and `isBookFilterActive`; -- `setBookViewMode`, `setBookSortOption`, `sortBooks`, `setBookSearchQuery`, `clearBookSearch`, filter mutation/reset methods, `getFilteredBooksForShelf`, and `getBooksForShelf`. - -Keep shelf collection controls, CRUD, `getChildShelves`, book membership mutations, count helpers, and cover previews. Remove unused `Book` and `LibraryReadingStatus` imports. - -- [ ] **Step 3: Format, analyze, and re-run the usage search** - -Run: - -```bash -cd app -dart format lib/providers/shelves_provider.dart -flutter analyze lib/providers/shelves_provider.dart lib/pages/shelves_page.dart lib/pages/shelf_contents_page.dart -cd .. -rg -n "BookSortOption|BookFilterType|bookSearchQuery|isBookGridView|isBookListView|setBookViewMode|getFilteredBooksForShelf|getBooksForShelf" app/lib --glob '*.dart' -``` - -Expected: analysis reports no issues and the search returns no matches. - -- [ ] **Step 4: Commit provider cleanup** - -```bash -git add app/lib/providers/shelves_provider.dart -git commit -m "PPR-25: Remove legacy shelf book controls" -``` - -### Task 8: Verify the Integrated Experience - -**Files:** -- Verify only; do not add test files. - -- [ ] **Step 1: Run the complete targeted analyzer** - -```bash -cd app -flutter analyze \ - lib/models/library_filter_options.dart \ - lib/models/shelf.dart \ - lib/providers/library_provider.dart \ - lib/providers/shelves_provider.dart \ - lib/pages/library_page.dart \ - lib/pages/shelf_contents_page.dart \ - lib/pages/shelves_page.dart \ - lib/widgets/library/library_filter_chips.dart \ - lib/widgets/library/library_advanced_filter_sheet.dart \ - lib/widgets/shelves/add_shelf_sheet.dart -``` - -Expected: `No issues found!`. - -- [ ] **Step 2: Build the web client** - -```bash -flutter build web --debug -``` - -Expected: exit code 0 and a completed debug web build. - -- [ ] **Step 3: Check patch hygiene and preservation of prior work** - -```bash -cd .. -git diff --check -git status --short -``` - -Expected: no whitespace errors. The pre-existing `app/lib/widgets/shelves/shelves_filter_chips.dart` modification remains visible unless separately committed by the user. - -- [ ] **Step 4: Perform manual behavior verification** - -Verify: - -- Books filters do not carry into a shelf and shelf filters do not carry back. -- Only direct shelf members appear; child shelves never render. -- Quick and advanced option lists contain only metadata and memberships present in the unfiltered shelf. -- Advanced preview counts match applied results. -- Search, all structured filters, sorting, small grid, large grid, list, selection, bulk actions, and book navigation match Books behavior. -- Mobile and desktop identity headers show the selected colored icon, name, description, placeholder, and Edit action without tinted containers. -- Editing name, description, color, and icon refreshes immediately; clearing a description persists. -- `Add to shelf` remains visually enabled but changes no data. -- Empty shelf, filtered no-results, deleted shelf, and Back navigation states are correct. - -- [ ] **Step 5: Review final commit and worktree scope** - -```bash -git log --oneline -8 -git status --short -``` - -Do not stage or commit unrelated user changes. diff --git a/docs/superpowers/plans/2026-08-01-shelf-page-heading.md b/docs/superpowers/plans/2026-08-01-shelf-page-heading.md deleted file mode 100644 index 6be0a8d..0000000 --- a/docs/superpowers/plans/2026-08-01-shelf-page-heading.md +++ /dev/null @@ -1,157 +0,0 @@ -# Shelf Page Heading Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the shelf books toolbar-like identity row with a compact, responsive page heading that keeps the shelf icon, title, description, and edit action visually grouped. - -**Architecture:** Keep the change inside the shelf variant of `LibraryPage`. Pass an explicit compact flag from the existing mobile and desktop header builders, and let `_buildShelfIdentity` render one shared semantic structure with breakpoint-specific typography and edit controls. Search, chips, grid, navigation, filtering, and provider behavior remain unchanged. - -**Tech Stack:** Flutter, Dart, Material 3, existing Papyrus design tokens - ---- - -### Task 1: Restructure the shelf identity as a page heading - -**Files:** -- Modify: `app/lib/pages/library_page.dart:277-301` -- Modify: `app/lib/pages/library_page.dart:714-765` -- Test: none, per the established request not to add or modernize tests for this work - -- [ ] **Step 1: Distinguish the mobile and desktop heading treatments** - -Update the two shelf-only call sites so mobile requests the compact treatment and desktop requests the full treatment: - -```dart -_buildShelfIdentity(context, showBack: true, compact: true) -``` - -```dart -_buildShelfIdentity(context, showBack: true, compact: false) -``` - -Do not change the surrounding search row, Add to shelf action, chips, or selection-mode branching. - -- [ ] **Step 2: Replace the toolbar-like identity row** - -Change the helper signature and build a constrained content column after the existing Back and shelf-icon controls: - -```dart -Widget _buildShelfIdentity( - BuildContext context, { - required bool showBack, - required bool compact, -}) { - final shelf = widget.shelf!; - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final description = shelf.description?.trim(); - final titleStyle = compact ? textTheme.titleLarge : textTheme.headlineSmall; - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showBack && widget.onBack != null) - IconButton( - onPressed: widget.onBack, - icon: const Icon(Icons.arrow_back), - tooltip: 'Back to shelves', - ), - Padding( - padding: const EdgeInsets.only(top: Spacing.sm), - child: Icon( - shelf.displayIcon, - size: IconSizes.medium, - color: shelf.color ?? colorScheme.primary, - ), - ), - const SizedBox(width: Spacing.md), - Flexible( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 720), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - shelf.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle?.copyWith(fontWeight: FontWeight.w600), - ), - ), - if (widget.onEditShelf != null) ...[ - const SizedBox(width: Spacing.sm), - if (compact) - IconButton( - onPressed: widget.onEditShelf, - icon: const Icon(Icons.edit_outlined), - tooltip: 'Edit shelf', - ) - else - TextButton.icon( - onPressed: widget.onEditShelf, - icon: const Icon(Icons.edit_outlined, size: IconSizes.small), - label: const Text('Edit'), - ), - ], - ], - ), - const SizedBox(height: Spacing.xs), - Text( - description == null || description.isEmpty - ? 'Add a description' - : description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ], - ); -} -``` - -Preserve this structure and the listed token-sized spacing. Do not add a background, border, shadow, icon container, or additional action. - -- [ ] **Step 3: Format and run targeted static analysis** - -Run: - -```bash -dart format app/lib/pages/library_page.dart -flutter analyze app/lib/pages/library_page.dart app/lib/pages/shelf_contents_page.dart -``` - -Expected: formatting completes successfully and analysis reports `No issues found!`. - -- [ ] **Step 4: Manually verify the responsive heading** - -Check desktop and mobile/narrow layouts and confirm: - -- the title uses page-heading typography; -- Edit stays within the constrained title block rather than at the viewport edge; -- the description aligns with the title, wraps to at most two lines, and ellipsizes; -- the missing-description prompt still appears; -- Back and Edit retain tooltips and accessible touch targets; -- the main Books header is unchanged; -- search, chips, grid, selection mode, and Add to shelf do not move or change behavior beyond the intentional heading-height adjustment. - -- [ ] **Step 5: Check the diff and commit** - -Run: - -```bash -git diff --check -git diff -- app/lib/pages/library_page.dart -git add app/lib/pages/library_page.dart -git commit -m "PPR-25: Refine shelf page heading" -``` - -Expected: one focused production-file commit with no unrelated changes. diff --git a/docs/superpowers/plans/2026-08-01-shelves-page-controls.md b/docs/superpowers/plans/2026-08-01-shelves-page-controls.md deleted file mode 100644 index 3a5e3c6..0000000 --- a/docs/superpowers/plans/2026-08-01-shelves-page-controls.md +++ /dev/null @@ -1,270 +0,0 @@ -# Shelves Page Controls Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Books-style chip row to the main Shelves page for contents filtering, type filtering, explicit sorting, and Small grid/Large grid/List display modes. - -**Architecture:** Keep all shelf collection state and list transformation in `ShelvesProvider`. Add a shelf-specific chip widget with single-selection bottom sheets, then simplify `ShelvesPage` so mobile and desktop share the same search → chips → results structure. Separate the main shelf-collection view mode from the existing shelf-contents book view mode so this feature does not change the controls inside a shelf. - -**Tech Stack:** Flutter, Dart, Provider, Material 3, Papyrus design tokens - ---- - -### Task 1: Separate Shelf Collection State from Shelf-Contents State - -**Files:** -- Modify: `app/lib/providers/shelves_provider.dart:7-205` -- Modify: `app/lib/pages/shelf_contents_page.dart:300-355` - -- [ ] **Step 1: Define the shelf collection enums and defaults** - -Replace the ambiguous two-state main-page view model with: - -```dart -enum ShelvesViewMode { smallGrid, largeGrid, list } - -enum ShelfContentsFilter { all, withBooks, empty } - -enum ShelfTypeFilter { all, regular, smart } -``` - -Keep `ShelfSortOption { name, bookCount, dateCreated, dateModified }` and its ascending flag. Add a separate private boolean for the existing shelf-contents grid/list control: - -```dart -ShelvesViewMode _viewMode = ShelvesViewMode.smallGrid; -bool _isBookGridView = true; -ShelfContentsFilter _contentsFilter = ShelfContentsFilter.all; -ShelfTypeFilter _typeFilter = ShelfTypeFilter.all; -``` - -- [ ] **Step 2: Add collection-control getters and setters** - -Expose `contentsFilter`, `typeFilter`, `viewMode`, `isSmallGridView`, `isLargeGridView`, and collection `isListView`. Add idempotent setters for contents, type, sort field plus explicit direction, and view mode. Add: - -```dart -bool get hasActiveShelfControls => - _contentsFilter != ShelfContentsFilter.all || - _typeFilter != ShelfTypeFilter.all || - _shelfSortOption != ShelfSortOption.name || - !_shelfSortAscending || - _viewMode != ShelvesViewMode.smallGrid; - -void clearShelfControls() { - _contentsFilter = ShelfContentsFilter.all; - _typeFilter = ShelfTypeFilter.all; - _shelfSortOption = ShelfSortOption.name; - _shelfSortAscending = true; - _viewMode = ShelvesViewMode.smallGrid; - notifyListeners(); -} -``` - -Do not clear `_searchQuery` here. - -- [ ] **Step 3: Apply shelf filters before sorting** - -Update `shelves` to apply name/description search, then: - -```dart -switch (_contentsFilter) { - case ShelfContentsFilter.all: - break; - case ShelfContentsFilter.withBooks: - list = list.where((shelf) => _dataStore!.getBookCountForShelf(shelf.id) > 0).toList(); - case ShelfContentsFilter.empty: - list = list.where((shelf) => _dataStore!.getBookCountForShelf(shelf.id) == 0).toList(); -} - -switch (_typeFilter) { - case ShelfTypeFilter.all: - break; - case ShelfTypeFilter.regular: - list = list.where((shelf) => !shelf.isSmart).toList(); - case ShelfTypeFilter.smart: - list = list.where((shelf) => shelf.isSmart).toList(); -} -``` - -Call `_applySorting` last. Add a `hasAnyShelves` getter based on the unfiltered `DataStore` collection so the page can distinguish no data from no matches. - -- [ ] **Step 4: Preserve shelf-contents grid/list behavior** - -Add `isBookGridView`, `isBookListView`, and `setBookViewMode(bool isGrid)` around `_isBookGridView`. Update `shelf_contents_page.dart` to use those APIs instead of the main collection `viewMode`, `isGridView`, and `isListView`. Do not change shelf-contents layout or chip behavior. - -- [ ] **Step 5: Verify the provider layer** - -Run: - -```bash -flutter analyze lib/providers/shelves_provider.dart lib/pages/shelf_contents_page.dart -``` - -Expected: no issues. - -### Task 2: Build the Shelf Filter Chip Row - -**Files:** -- Create: `app/lib/widgets/shelves/shelves_filter_chips.dart` - -- [ ] **Step 1: Add focused presentation types** - -Create private `_ChipEntry`, `_SelectionOption`, `_DropdownFilterChip`, and `_SingleSelectionSheet` types modeled on `library_filter_chips.dart`. Keep them local to the shelf widget; do not expose or refactor the Books filter implementation. - -Use `ActionChip` with: - -- category-specific semantics and selected state; -- an 18 px leading icon and trailing arrow; -- `secondaryContainer` for active controls; -- compact visual density and `AppRadius.full` shape; -- a transparent border in the selected state when needed to keep intrinsic height stable. - -Present selections with `showModalBottomSheet(useRootNavigator: true)` and a list whose selected row has a trailing check. - -- [ ] **Step 2: Define explicit options** - -Contents options: All, With books, Empty. - -Type options: All, Regular, Smart. - -Sort options use a private value record containing both `ShelfSortOption` and `ascending`: - -```dart -typedef _ShelfSortSelection = ({ShelfSortOption option, bool ascending}); -``` - -Define all eight approved sort labels. View options are Small grid, Large grid, and List. - -- [ ] **Step 3: Build active-first ordering and Clear all** - -Construct entries in default order Contents, Sort, Type, View. Treat defaults as inactive. Stable-sort entries so active controls render first, then show them in a fixed-height horizontal `ListView.separated` matching the Books chip-row padding and spacing. - -When `provider.hasActiveShelfControls` is true, append a compact `Clear all` text action that calls `provider.clearShelfControls`. Text search remains unchanged. - -- [ ] **Step 4: Verify the chip widget** - -Run: - -```bash -flutter analyze lib/widgets/shelves/shelves_filter_chips.dart -``` - -Expected: no issues. - -### Task 3: Integrate One Shared Control Pattern into ShelvesPage - -**Files:** -- Modify: `app/lib/pages/shelves_page.dart:88-330` - -- [ ] **Step 1: Remove legacy header controls** - -Delete `_buildSortButton`, `_buildSortMenuItem`, and `_buildViewToggle`. Remove the shared `ViewModeToggle` import. Add the `ShelvesFilterChips` import. - -Keep `_buildSearchField`, the mobile menu button, the mobile floating New shelf action, and the desktop New shelf button. - -- [ ] **Step 2: Place the chip row below search on mobile** - -Build the mobile control stack as: - -1. Menu plus expanded search field. -2. `Spacing.sm` vertical gap. -3. `ShelvesFilterChips`. -4. Results. - -Remove the shelf-count/view-toggle row so the chip row occupies a stable position equivalent to Books. - -- [ ] **Step 3: Place the chip row below search on desktop** - -Keep Search and New shelf in the desktop header row, including compact-width wrapping if required. Put `ShelvesFilterChips` on the next line with `Spacing.sm` separation. Do not move sort or view back into the search row at wide widths. - -- [ ] **Step 4: Render from one visible list per build** - -Capture `final shelves = provider.shelves` once in each layout and pass it into `_buildShelfGrid` or `_buildShelfList`. Update those helpers to accept `List` rather than repeatedly reading the provider getter. - -Select the result widget as follows: - -```dart -if (!provider.hasAnyShelves) { - return _buildEmptyState(context); -} -if (shelves.isEmpty) { - return _buildNoResultsState(context); -} -if (provider.viewMode == ShelvesViewMode.list) { - return _buildShelfList(context, shelves); -} -return _buildShelfGrid(context, shelves, provider.viewMode); -``` - -- [ ] **Step 5: Implement responsive Small and Large grid density** - -Keep existing aspect ratios and spacing. Resolve columns from the selected mode: - -| Breakpoint | Small grid | Large grid | -|---|---:|---:| -| Phone | 2 | 2 | -| Tablet | 4 | 3 | -| Small desktop | 5 | 3 | -| Large desktop | 6 | 4 | - -- [ ] **Step 6: Add the filtered no-results state** - -Add `_buildNoResultsState` using `EmptyState` with title `No shelves found` and guidance to change search or filters. Do not include the Create shelf action. Retain the existing creation-focused empty state when `hasAnyShelves` is false. - -- [ ] **Step 7: Verify page integration** - -Run: - -```bash -flutter analyze lib/providers/shelves_provider.dart lib/pages/shelves_page.dart lib/pages/shelf_contents_page.dart lib/widgets/shelves/shelves_filter_chips.dart -``` - -Expected: no issues. - -### Task 4: Final Verification - -**Files:** -- Verify: `app/lib/providers/shelves_provider.dart` -- Verify: `app/lib/pages/shelves_page.dart` -- Verify: `app/lib/pages/shelf_contents_page.dart` -- Verify: `app/lib/widgets/shelves/shelves_filter_chips.dart` - -- [ ] **Step 1: Format and validate the diff** - -Run: - -```bash -dart format app/lib/providers/shelves_provider.dart app/lib/pages/shelves_page.dart app/lib/pages/shelf_contents_page.dart app/lib/widgets/shelves/shelves_filter_chips.dart -git diff --check -``` - -- [ ] **Step 2: Run final static analysis** - -Run the four-file targeted `flutter analyze` command from Task 3. - -- [ ] **Step 3: Build the web application** - -Run: - -```bash -flutter build web --debug -``` - -Expected: build succeeds. - -- [ ] **Step 4: Manually verify behavior** - -- Confirm search matches shelf names and descriptions. -- Confirm Contents and Type combine with AND logic. -- Confirm every explicit sort direction. -- Confirm Small grid, Large grid, and List on phone, tablet, and desktop widths. -- Confirm active chips move first without moving the results vertically. -- Confirm Clear all preserves text search. -- Confirm `No shelves yet` and `No shelves found` appear in the correct states. -- Confirm shelf contents still switch between their existing grid and list views. - -- [ ] **Step 5: Commit the implementation** - -```bash -git add app/lib/providers/shelves_provider.dart app/lib/pages/shelves_page.dart app/lib/pages/shelf_contents_page.dart app/lib/widgets/shelves/shelves_filter_chips.dart docs/superpowers/plans/2026-08-01-shelves-page-controls.md -git commit -m "PPR-25: Add shelves page controls" -``` diff --git a/docs/superpowers/plans/2026-08-23-book-import-drop-zone.md b/docs/superpowers/plans/2026-08-23-book-import-drop-zone.md deleted file mode 100644 index ccb8546..0000000 --- a/docs/superpowers/plans/2026-08-23-book-import-drop-zone.md +++ /dev/null @@ -1,95 +0,0 @@ -# Book Import Drop Zone Implementation Plan - -> Execute inline in the current feature worktree. Keep all changes uncommitted. - -**Goal:** Replace the compact purple picker panel with a full-body Material 3 drop zone that supports real desktop/web drag-and-drop while preserving picker and import behavior. - -**Architecture:** Keep dropped-file conversion at the widget boundary. A focused `BookImportDropZone` owns hover, focus, drag, and file-read state; it emits framework-neutral `SelectedBookFile` values and feedback to `BookImportController` through a small public command. `BookImportSelectingSection` continues to swap the empty state for the existing selected-file list. - -**Tech:** Flutter Material 3, `desktop_drop`, `file_picker`, widget tests, controller unit tests. - ---- - -## Task 1: Define dropped-selection controller behavior - -**Files:** -- Modify: `app/test/widgets/add_book/book_import_controller_test.dart` -- Modify: `app/lib/widgets/add_book/book_import_controller.dart` - -1. Add a failing controller test which calls the wished-for API: - -```dart -controller.applyDroppedFiles( - [SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1]))], - feedback: 'Some files were skipped because their format is not supported.', -); - -expect(controller.files.single.name, 'book.epub'); -expect(controller.pickerError, contains('skipped')); -``` - -2. Run `flutter test test/widgets/add_book/book_import_controller_test.dart` from `app/` and confirm it fails because `applyDroppedFiles` does not exist. -3. Implement the minimal controller command. It must ignore calls after disposal, store an unmodifiable non-empty selection, and allow feedback without replacing the current empty state: - -```dart -void applyDroppedFiles(List files, {String? feedback}) { - if (_disposed) return; - _update(() { - if (files.isNotEmpty) _files = List.unmodifiable(files); - _pickerError = feedback; - }); -} -``` - -4. Re-run the focused test and confirm it passes. - -## Task 2: Add and test the Material 3 drop-zone widget - -**Files:** -- Modify: `app/pubspec.yaml` -- Modify: `app/pubspec.lock` via `flutter pub get` -- Create: `app/lib/widgets/add_book/book_import_drop_zone.dart` -- Modify: `app/test/widgets/add_book/book_import_sheet_test.dart` - -1. Add failing widget tests for the public drop-zone surface: - - desktop copy is `Drag and drop book files here`; - - mobile copy is `Choose book files`; - - supported-format copy and `Browse files` remain visible; - - invoking the drop conversion callback with supported and unsupported entries produces selected files plus inline feedback. -2. Run the focused widget test and confirm failure because `BookImportDropZone` does not exist. -3. Add `desktop_drop: ^0.8.0` with `apply_patch`, run `flutter pub get`, and inspect the installed package API before coding against it. -4. Implement `BookImportDropZone` as a stateful widget: - - fill parent constraints; - - transparent rest surface and dashed `outlineVariant` rounded border; - - primary upload icon, `titleMedium` instruction, `bodyMedium` formats, outlined browse button; - - hover/focus neutral state layer and visible focus border; - - drag-over primary border, primary icon, and faint `primaryContainer` tint; - - disabled browse activation and progress indicator while picking or reading drops; - - keyboard activation through standard Flutter focus/actions; - - desktop/web drop support only, with mobile retaining the identical picker layout; - - async parallel reads of supported entries, unreadable entries represented with `bytes: null`; - - exact unsupported-only feedback `No supported book files were dropped.` and a mixed-drop skipped warning. -5. Implement a private custom painter using `PathMetric.extractPath` to draw the rounded dashed border without another visual package. -6. Run the focused widget test and confirm it passes. - -## Task 3: Wire the drop zone into the selecting section - -**Files:** -- Modify: `app/lib/widgets/add_book/book_import_sheet_sections.dart` -- Modify: `app/lib/widgets/add_book/book_import_sheet.dart` -- Modify: `app/test/widgets/add_book/book_import_sheet_test.dart` - -1. Add a failing sheet test proving the empty-state drop zone expands within `Spacing.lg` body insets and that a delivered dropped selection replaces it with the existing file list. -2. Replace `_BrowseArea` with `Expanded(child: BookImportDropZone(...))` inside an all-sides `Spacing.lg` inset. Keep error feedback adjacent and preserve the selected-file list unchanged. -3. Add `onDroppedFiles` to `BookImportSelectingSection` and wire it to `BookImportController.applyDroppedFiles` from `BookImportSheet`. -4. Run `flutter test test/widgets/add_book/book_import_sheet_test.dart` and fix only regressions caused by the new design. - -## Task 4: Verify and review - -**Files:** all modified files above. - -1. Run `dart format` on modified Dart files. -2. Run `flutter analyze` from `app/`; expect no issues. -3. Run `flutter test test/widgets/add_book`; expect the add-book suite to pass. -4. Run `git diff --check` and inspect `git diff --stat` plus the focused upload-zone diff. -5. Review light/dark semantic colors, keyboard focus, 48dp touch targets, loading feedback, and narrow-window text wrapping against the approved design. Leave the result uncommitted. diff --git a/docs/superpowers/plans/2026-08-23-book-import-sheet-refactor.md b/docs/superpowers/plans/2026-08-23-book-import-sheet-refactor.md deleted file mode 100644 index 3920857..0000000 --- a/docs/superpowers/plans/2026-08-23-book-import-sheet-refactor.md +++ /dev/null @@ -1,276 +0,0 @@ -# Book Import Sheet Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Split the 1,151-line book import sheet into a controller and focused presentation widgets without changing import behavior. - -**Architecture:** A `ChangeNotifier` controller owns the existing async state machine and exposes read-only state plus commands. `BookImportSheet` retains provider/modal integration and UI-only side effects, focused section widgets render each phase, and one shared item card centralizes repeated presentation. - -**Tech Stack:** Flutter 3.44, Dart 3.12, Provider, `flutter_test` - ---- - -## File Structure - -- Create `app/lib/widgets/add_book/book_import_controller.dart`: import state, async pipeline, retries, cleanup, and close results. -- Create `app/lib/widgets/add_book/book_import_item_card.dart`: shared processing/summary item presentation. -- Create `app/lib/widgets/add_book/book_import_sheet_sections.dart`: selecting, processing, and summary layouts. -- Modify `app/lib/widgets/add_book/book_import_sheet.dart`: public modal wiring and controller-backed composition only. -- Create `app/test/widgets/add_book/book_import_controller_test.dart`: controller state and lifecycle tests. -- Create `app/test/widgets/add_book/book_import_sheet_test.dart`: behavior-preserving widget coverage. - -### Task 1: Extract and test the controller - -**Files:** -- Create: `app/lib/widgets/add_book/book_import_controller.dart` -- Create: `app/test/widgets/add_book/book_import_controller_test.dart` - -- [ ] **Step 1: Write failing controller tests** - -Create injected fakes and cover success, parse retry, commit-only retry, close cleanup, cleanup deduplication, and one-shot completion through this API: - -```dart -final controller = BookImportController( - pickFiles: () async => [SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1]))], - processor: (bytes, filename) async => result, - committer: (result, filename) async => book, - deleteBookFile: deletedBookIds.add, - onCompleted: completions.add, -); - -await controller.browse(); -controller.startImport(); -await pumpEventQueue(); - -expect(controller.phase, BookImportPhase.summary); -expect(controller.items.single.status, BookImportBatchStatus.added); -expect(completions, hasLength(1)); -``` - -For close cleanup, hold processing with a `Completer`, call `requestClose()`, complete processing, and assert that close waits and deletes the temporary result. - -- [ ] **Step 2: Run the tests and verify they fail** - -```bash -cd app -flutter test test/widgets/add_book/book_import_controller_test.dart -``` - -Expected: compilation fails because the controller does not exist. - -- [ ] **Step 3: Implement the controller contract** - -```dart -typedef DigitalBookFilePicker = Future> Function(); -typedef BookImportProcessor = Future Function(Uint8List bytes, String filename); -typedef ImportedBookFileDeleter = Future Function(String bookId); -typedef ImportedBookCommitter = Future Function(BookImportResult result, String sourceFilename); - -enum BookImportPhase { selecting, processing, summary } -enum BookImportCloseResult { closed, processingCleanupFailed, cleanupFailed } -enum BookImportRemoveResult { removed, ignored, cleanupFailed } - -class BookImportController extends ChangeNotifier { - BookImportController({ - required DigitalBookFilePicker pickFiles, - required BookImportProcessor processor, - required ImportedBookFileDeleter deleteBookFile, - required ImportedBookCommitter committer, - ValueChanged>? onCompleted, - }); - - BookImportPhase get phase; - List get files; - List get readableFiles; - List get items; - bool get isPicking; - String? get pickerError; - bool get isClosing; - bool get allSettled; - bool get anyProcessing; - int get successCount; - int get failureCount; - - Future browse(); - void removeFile(SelectedBookFile file); - void clearSelection(); - void startImport(); - Future retryItem(String id); - Future removeItem(String id); - Future requestClose(); -} -``` - -Move the existing orchestration without changing ordering or parallel behavior. Preserve processing-token validation, in-flight processing and cleanup maps, cleaned IDs, and close-future deduplication. Replace `mounted` with `_disposed`; call `notifyListeners()` only while active. - -- [ ] **Step 4: Run controller tests** - -Run the Step 2 command. Expected: all controller tests pass. - -### Task 2: Extract shared item presentation - -**Files:** -- Create: `app/lib/widgets/add_book/book_import_item_card.dart` -- Create: `app/test/widgets/add_book/book_import_sheet_test.dart` - -- [ ] **Step 1: Write failing item-card widget tests** - -Cover processing, added, and failed states, including action visibility: - -```dart -await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: BookImportItemCard( - item: failedItem, - presentation: BookImportItemCardPresentation.progress, - onRetry: () {}, - onRemove: () {}, - ), - ), -)); -expect(find.text('Retry'), findsOneWidget); -expect(find.byTooltip('Remove failed.epub'), findsOneWidget); -``` - -- [ ] **Step 2: Run the focused test and verify failure** - -```bash -cd app -flutter test test/widgets/add_book/book_import_sheet_test.dart -``` - -Expected: compilation fails because the shared card does not exist. - -- [ ] **Step 3: Implement the shared card** - -```dart -enum BookImportItemCardPresentation { progress, summary } - -class BookImportItemCard extends StatelessWidget { - const BookImportItemCard({ - super.key, - required this.item, - required this.presentation, - this.onRetry, - this.onRemove, - }); - - final BookImportBatchItem item; - final BookImportItemCardPresentation presentation; - final VoidCallback? onRetry; - final VoidCallback? onRemove; -} -``` - -Move the existing display title, subtitle, cover, fallback icon, status, and actions here. Preserve keys, animation durations, colors, spacing, tooltips, max lines, and labels. Use the enum only for genuine progress/summary differences. - -- [ ] **Step 4: Run the focused widget test** - -Run the Step 2 command. Expected: item-card tests pass. - -### Task 3: Extract phases and rewire the sheet - -**Files:** -- Create: `app/lib/widgets/add_book/book_import_sheet_sections.dart` -- Modify: `app/lib/widgets/add_book/book_import_sheet.dart` -- Modify: `app/test/widgets/add_book/book_import_sheet_test.dart` - -- [ ] **Step 1: Add failing full-sheet tests** - -Pump the sheet with injected callbacks and cover browse, import, summary, picker failure, retry, cleanup-failure snackbar, close, and the existing expanded browse layout: - -```dart -await tester.tap(find.text('Browse files')); -await tester.pump(); -expect(find.text('1 file selected'), findsOneWidget); -await tester.tap(find.text('Import 1 book')); -await tester.pumpAndSettle(); -expect(find.text('Import complete'), findsOneWidget); -expect(find.text('1 book added'), findsOneWidget); -``` - -- [ ] **Step 2: Run the sheet test before rewiring** - -Run the Task 2 test command. Expected: the new extraction-specific assertions fail. - -- [ ] **Step 3: Move phase UI into focused widgets** - -Create `BookImportSelectingSection`, `BookImportProcessingSection`, and `BookImportSummarySection`. Pass immutable values and callbacks; do not add async state. Keep the browse area and selected-file card private to this file, preserving `mainAxisSize: MainAxisSize.max`. Use `BookImportItemCard` for processing and summary lists. - -- [ ] **Step 4: Rewire `BookImportSheet`** - -Keep `show()` and `_commitResult()` unchanged. Create the controller with closures that reference the current widget callbacks: - -```dart -late final BookImportController _controller; - -@override -void initState() { - super.initState(); - _controller = BookImportController( - pickFiles: () => widget.pickFiles(), - processor: (bytes, filename) => widget.processor(bytes, filename), - deleteBookFile: (bookId) => widget.deleteBookFile(bookId), - committer: (result, filename) => widget.committer(result, filename), - onCompleted: (books) => widget.onCompleted?.call(books), - ); -} - -@override -void dispose() { - _controller.dispose(); - super.dispose(); -} -``` - -Render with `ListenableBuilder` and switch on `controller.phase`. Convert controller close/remove results into the existing snackbars and call `widget.onClose()` only for `BookImportCloseResult.closed`. Re-export callback typedefs from the controller file for source compatibility. - -- [ ] **Step 5: Run controller and sheet tests** - -```bash -cd app -flutter test test/widgets/add_book/book_import_controller_test.dart test/widgets/add_book/book_import_sheet_test.dart -``` - -Expected: all tests pass. - -### Task 4: Format and verify - -**Files:** Verify all files above. - -- [ ] **Step 1: Format changed Dart files** - -```bash -cd app -dart format lib/widgets/add_book/book_import_controller.dart lib/widgets/add_book/book_import_item_card.dart lib/widgets/add_book/book_import_sheet_sections.dart lib/widgets/add_book/book_import_sheet.dart test/widgets/add_book/book_import_controller_test.dart test/widgets/add_book/book_import_sheet_test.dart -``` - -Expected: formatting completes without errors. - -- [ ] **Step 2: Run static analysis** - -```bash -cd app -flutter analyze lib/widgets/add_book/book_import_controller.dart lib/widgets/add_book/book_import_item_card.dart lib/widgets/add_book/book_import_sheet_sections.dart lib/widgets/add_book/book_import_sheet.dart test/widgets/add_book/book_import_controller_test.dart test/widgets/add_book/book_import_sheet_test.dart -``` - -Expected: no issues found. - -- [ ] **Step 3: Run the add-book widget suite** - -```bash -cd app -flutter test test/widgets/add_book -``` - -Expected: all tests pass. - -- [ ] **Step 4: Inspect the final diff** - -```bash -git diff --check -git status --short -git diff --stat -``` - -Expected: no whitespace errors; only planned files, the uncommitted plan, and the pre-existing browse-area change appear. Leave implementation changes uncommitted for review. diff --git a/docs/superpowers/plans/2026-08-23-book-storage-status.md b/docs/superpowers/plans/2026-08-23-book-storage-status.md deleted file mode 100644 index 0f07f21..0000000 --- a/docs/superpowers/plans/2026-08-23-book-storage-status.md +++ /dev/null @@ -1,353 +0,0 @@ -# Per-book Account and Device Status Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Show server-confirmed per-book account state, visually mute digital books missing from the current device, and download a missing file only when reading starts. - -**Architecture:** Extend the PowerSync boundary with an authoritative per-book metadata snapshot, then combine it with the existing media queue, `fileMediaId`, and an existence-only local-file probe in a focused controller. Cards consume resolved states; the details page owns download-and-read behavior. A generation guard prevents stale asynchronous global sync calculations from overwriting newer status. - -**Tech Stack:** Flutter/Dart, Provider, PowerSync SQLite, OPFS Web Worker, native filesystem storage, Material 3, `flutter_test`. - -**Note:** Commit steps are intentionally omitted because the user requested uncommitted work. - ---- - -### Task 1: Make PowerSync status current and expose per-book metadata state - -**Files:** -- Create: `app/lib/powersync/book_metadata_sync_state.dart` -- Modify: `app/lib/powersync/powersync_service.dart` -- Modify: `app/lib/powersync/papyrus_powersync_connector.dart` -- Modify: `app/lib/main.dart` -- Test: `app/test/powersync/powersync_service_test.dart` -- Test: `app/test/powersync/papyrus_powersync_connector_test.dart` - -- [ ] **Step 1: Write failing tests for metadata acknowledgement and stale status suppression** - -Add connector tests that build `CrudEntry` values for two books, assert that accepted book IDs are reported after `transaction.complete`, and assert that a definitive 4xx upload rejection reports the affected IDs. Add a service-level test seam for delayed pending-write reads and assert that an older `connected: false` calculation cannot replace a later `connected: true` calculation. - -```dart -expect(acknowledgedBookIds, {'book-1', 'book-2'}); -expect(failedBookIds, {'book-1', 'book-2'}); -expect(service.syncState.connected, isTrue); -``` - -- [ ] **Step 2: Run the focused tests and confirm the new expectations fail** - -```bash -cd app -flutter test test/powersync/papyrus_powersync_connector_test.dart -flutter test test/powersync/powersync_service_test.dart -``` - -Expected: failures because per-book callbacks/snapshots and superseded-status protection do not exist. - -- [ ] **Step 3: Add the metadata snapshot model** - -```dart -class BookMetadataSyncState { - const BookMetadataSyncState({this.pendingBookIds = const {}, this.failedBookIds = const {}}); - - final Set pendingBookIds; - final Set failedBookIds; - - bool isPending(String bookId) => pendingBookIds.contains(bookId); - bool hasFailed(String bookId) => failedBookIds.contains(bookId); -} -``` - -- [ ] **Step 4: Report transaction outcomes from the connector** - -Preserve the existing media-upload trigger and add book-specific callbacks. Collect only entries whose `table == 'books'`. Invoke acknowledgement only after the server batch succeeds and `transaction.complete()` finishes. Report only non-retryable client errors as definitive; authentication, timeout, rate-limit, connection, and 5xx failures remain pending. - -```dart -final Future Function(Set bookIds)? onBooksAcknowledged; -final void Function(Set bookIds, Object error)? onBooksRejected; -``` - -- [ ] **Step 5: Derive pending IDs inside `PapyrusPowerSyncService`** - -Read `SELECT data FROM ps_crud`, decode each JSON payload, and collect `id` where `type == 'books'`. Refresh after local `upsert`/`delete`, on PowerSync status changes, and after connector acknowledgement. Publish a broadcast snapshot and keep the latest value available synchronously. - -```dart -BookMetadataSyncState get bookMetadataSyncState => _bookMetadataSyncState; -Stream get bookMetadataSyncStates => _bookMetadataSyncStateController.stream; -``` - -Clear a book's recorded failure when a new local mutation is made or the book is acknowledged. - -- [ ] **Step 6: Prevent stale global status publication** - -Increment a generation before each asynchronous status conversion. After the pending-write query completes, discard the result unless it still owns the newest generation. - -```dart -final generation = ++_syncStatusGeneration; -final pending = await _hasPendingWrites(); -if (generation != _syncStatusGeneration) return; -``` - -- [ ] **Step 7: Wire connector callbacks in `main.dart`** - -Pass acknowledged/rejected IDs back into the already-created PowerSync service and retain `_processMediaUploads` as the general successful-upload callback. - -- [ ] **Step 8: Run focused tests** - -Run the two commands from Step 2. Expected: all tests pass. - ---- - -### Task 2: Add an existence-only local book-file API - -**Files:** -- Modify: `app/lib/services/book_import_service.dart` -- Modify: `app/lib/services/book_import_service_stub.dart` -- Modify: `app/web/book_worker.js` -- Test: `app/test/services/book_import_service_test.dart` -- Create: `app/test/services/book_import_service_web_test.dart` - -- [ ] **Step 1: Write failing native and web contract tests** - -Test that `hasBookFile(bookId)` is false before storage, true after storage/import, and false after deletion. The web test must run under Chrome and exercise the worker without returning file bytes. - -```dart -expect(await service.hasBookFile('book-1'), isFalse); -await service.storeBookFile('book-1', 'epub', bytes); -expect(await service.hasBookFile('book-1'), isTrue); -``` - -- [ ] **Step 2: Run both focused tests and confirm failure** - -```bash -cd app -flutter test test/services/book_import_service_test.dart -flutter test --platform chrome test/services/book_import_service_web_test.dart -``` - -Expected: compile failure because `hasBookFile` and the worker action do not exist. - -- [ ] **Step 3: Implement native existence checks** - -In the native/stub service, reuse the books directory and match the same safe book-ID basename used by `getBookFile`, but never call `readAsBytes`. - -```dart -Future hasBookFile(String bookId) async { - final booksDir = await _getBooksDirectory(); - return booksDir.listSync().whereType().any( - (file) => p.basenameWithoutExtension(file.path) == bookId, - ); -} -``` - -- [ ] **Step 4: Implement the OPFS worker action** - -Add `hasFile` to the documented worker protocol. `opfsHasFile(bookId)` checks the books directory and known extensions with `getFileHandle(..., create: false)` but never calls `getFile()` or `arrayBuffer()`. The Dart web service sends `{type: 'hasFile', bookId}` and parses a boolean `exists` response with the same timeout/error cleanup used by other worker requests. - -- [ ] **Step 5: Run both focused tests** - -Run the commands from Step 2. Expected: native and Chrome tests pass. - ---- - -### Task 3: Combine account and device states in one controller - -**Files:** -- Create: `app/lib/providers/book_storage_status_controller.dart` -- Modify: `app/lib/main.dart` -- Test: `app/test/providers/book_storage_status_controller_test.dart` - -- [ ] **Step 1: Write the failing status truth-table tests** - -Cover authenticated physical and digital books, guest books, pending metadata, definitive metadata failure, pending/failed book-file tasks, missing `fileMediaId`, and server-confirmed saved state. Also test that device status starts at `checking`, probes once, caches the result, and can be invalidated. - -```dart -expect(controller.accountStatusFor(digitalBook), BookAccountStatus.syncing); -expect(controller.deviceStatusFor(digitalBook), BookDeviceStatus.checking); -await controller.ensureDeviceStatus(digitalBook); -expect(controller.deviceStatusFor(digitalBook), BookDeviceStatus.missing); -``` - -- [ ] **Step 2: Run the controller test and confirm failure** - -```bash -cd app -flutter test test/providers/book_storage_status_controller_test.dart -``` - -- [ ] **Step 3: Implement the controller** - -```dart -enum BookAccountStatus { syncing, saved, failed } -enum BookDeviceStatus { checking, available, missing } - -class BookStorageStatusController extends ChangeNotifier { - BookAccountStatus? accountStatusFor(Book book); - BookDeviceStatus? deviceStatusFor(Book book); - Future ensureDeviceStatus(Book book); - void invalidateDeviceStatus(String bookId); - void clearDeviceStatuses(); -} -``` - -Account-state precedence is failure, then pending, then saved. Digital `saved` additionally requires a non-empty `fileMediaId`; cover tasks never participate. A retryable media task is pending even when it carries an informational error message. - -Subscribe to `AuthProvider`, `MediaUploadQueue`, and the PowerSync metadata-state stream. Cache local probes by book ID and deduplicate in-flight probes. A probe exception leaves the state at `checking` instead of falsely returning `missing`. - -- [ ] **Step 4: Wire lifecycle in `main.dart`** - -Create the controller after auth, media queue, import service, and PowerSync service. Provide it through `ChangeNotifierProvider.value`, clear device state on profile/account scope changes, and dispose it before its dependencies. - -- [ ] **Step 5: Run the focused controller test** - -Run the command from Step 2. Expected: all truth-table and probe tests pass. - ---- - -### Task 4: Render account badges and missing-local tint on book cards - -**Files:** -- Modify: `app/lib/widgets/library/book_card.dart` -- Modify: `app/lib/pages/library_page.dart` -- Test: `app/test/widgets/library/book_card_test.dart` -- Test: `app/test/pages/library_page_test.dart` - -- [ ] **Step 1: Write failing widget tests for every presentation state** - -Test `Saved`, `Syncing…`, and `Sync failed` icon/text badges; no badge for guest/null account state; desaturated/tinted presentation only for `BookDeviceStatus.missing`; normal presentation for `checking`, `available`, and physical books; and semantic labels for account and device state. - -- [ ] **Step 2: Run focused card and library tests and confirm failure** - -```bash -cd app -flutter test test/widgets/library/book_card_test.dart -flutter test test/pages/library_page_test.dart -``` - -- [ ] **Step 3: Extend `BookCard` with resolved status inputs** - -```dart -final BookAccountStatus? accountStatus; -final BookDeviceStatus? deviceStatus; -``` - -Place an informational account badge at the cover's bottom-right while retaining the format badge at bottom-left. Use Material theme tokens, icon plus label, a desktop tooltip, and no separate tap handler. - -For missing digital files, apply a grayscale `ColorFiltered` treatment to the cover and a neutral themed card surface. Keep title/author contrast unchanged. Extend the card's explicit `Semantics` wrapper to announce the statuses without duplicating descendant semantics. - -- [ ] **Step 4: Resolve state in `LibraryPage`** - -Watch the optional `BookStorageStatusController`, request a deduplicated local probe for each visible ordinary book, and pass current values into `BookCard`. Acquisition placeholder/job cards remain unchanged. - -- [ ] **Step 5: Run focused card and library tests** - -Run the commands from Step 2. Expected: all pass with no overflow at existing mobile and desktop card sizes. - ---- - -### Task 5: Download only from the details-page reading action - -**Files:** -- Modify: `app/lib/pages/book_details_page.dart` -- Modify: `app/lib/widgets/book_details/book_header.dart` -- Modify: `app/lib/widgets/book_details/book_action_buttons.dart` -- Test: `app/test/pages/book_details_reader_test.dart` -- Test: `app/test/widgets/book_details/book_action_buttons_test.dart` - -- [ ] **Step 1: Write failing reading-state tests** - -Cover local EPUB opening immediately without download, saved remote EPUB showing `Download and read`, disabled downloading state, successful cache-and-open, inline failure with a `Try again` action, disabled pending/failed sync states, and unchanged physical/unsupported behavior. - -- [ ] **Step 2: Run the focused tests and confirm failure** - -```bash -cd app -flutter test test/pages/book_details_reader_test.dart -flutter test test/widgets/book_details/book_action_buttons_test.dart -``` - -- [ ] **Step 3: Add an explicit reading-action presentation model** - -```dart -class BookReadingAction { - const BookReadingAction({required this.label, required this.icon, this.enabled = true, this.loading = false}); - final String label; - final IconData icon; - final bool enabled; - final bool loading; -} -``` - -Pass it through `BookHeader` to `BookActionButtons`. Render a compact progress indicator when loading and preserve 48dp touch sizing. - -- [ ] **Step 4: Track details-page availability and download state** - -Probe device state after the book loads. Replace the current unconditional `fileMediaId != null` preparation branch with: - -1. open immediately when local file exists; -2. call `MediaCacheService.ensureBookFileCached` only for `Download and read`; -3. invalidate/re-probe the controller after the successful write; -4. open the reader after caching; -5. display a sanitized inline error and retry action on failure. - -Do not start a download when the card or details page opens. Use an indeterminate progress indicator because the current media API returns a complete byte buffer rather than progress events. - -- [ ] **Step 5: Run focused details and action-button tests** - -Run the commands from Step 2. Expected: all pass. - ---- - -### Task 6: Format and verify the integrated behavior - -- [ ] **Step 1: Format changed Dart files** - -```bash -cd app -dart format \ - lib/main.dart \ - lib/powersync \ - lib/providers/book_storage_status_controller.dart \ - lib/services/book_import_service.dart \ - lib/services/book_import_service_stub.dart \ - lib/pages/book_details_page.dart \ - lib/pages/library_page.dart \ - lib/widgets/book_details \ - lib/widgets/library/book_card.dart \ - test/powersync \ - test/providers/book_storage_status_controller_test.dart \ - test/services/book_import_service_test.dart \ - test/services/book_import_service_web_test.dart \ - test/pages/book_details_reader_test.dart \ - test/pages/library_page_test.dart \ - test/widgets/book_details/book_action_buttons_test.dart \ - test/widgets/library/book_card_test.dart -``` - -- [ ] **Step 2: Run static analysis** - -```bash -flutter analyze -``` - -Expected: `No issues found!` - -- [ ] **Step 3: Run focused sync, storage, card, and details suites** - -```bash -flutter test test/powersync test/providers/book_storage_status_controller_test.dart test/services/book_import_service_test.dart test/widgets/library/book_card_test.dart test/pages/book_details_reader_test.dart test/widgets/book_details/book_action_buttons_test.dart -flutter test --platform chrome test/services/book_import_service_web_test.dart -``` - -- [ ] **Step 4: Run broader affected widget suites** - -```bash -flutter test test/widgets/add_book test/pages/library_page_test.dart test/pages/profile_storage_sync_test.dart -``` - -- [ ] **Step 5: Build web and check whitespace** - -```bash -flutter build web --debug --dart-define-from-file=.dart_defines -git diff --check -``` - -Expected: web build succeeds and `git diff --check` produces no output. diff --git a/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md b/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md deleted file mode 100644 index c1c7703..0000000 --- a/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# Stable Local Cover Image Cache Design - -- Status: Approved design -- Date: 2026-07-11 -- Audience: Papyrus client engineers - -## Overview - -Papyrus persists private cover bytes in filesystem storage or browser OPFS. A newly mounted `CoverImage` currently reads those bytes asynchronously and renders its placeholder until the read completes. Navigating between the library, book details, shelves, and list layouts therefore produces a visible cover flash even when the cover was already displayed moments earlier. - -This design gives each local cover a stable Flutter image-provider identity. Flutter's bounded least-recently-used `ImageCache` can then reuse the decoded image across widget instances and page transitions, while filesystem storage remains authoritative across app restarts. - -## Goals - -- Keep a previously displayed cover visually stable across page and layout transitions. -- Reuse Flutter's existing bounded decoded-image cache instead of adding an unbounded byte cache. -- Support account cached covers, account pending covers, and permanent guest covers. -- Preserve the existing local-first and cross-device storage behavior. -- Keep the first load and error behavior deterministic and testable. - -## Non-goals - -- Persist decoded images outside the current app session. -- Replace OPFS or native filesystem cover storage. -- Change server media endpoints, PowerSync schemas, or media synchronization. -- Preload every cover in a large library. -- Resize or recompress imported covers in this change. - -## Constraints - -- Cover bytes must not be stored in SQL, PowerSync rows, or `SharedPreferences`. -- Cache keys must include the server/account scope so private covers cannot cross profiles or users. -- Guest covers must remain isolated from authenticated libraries. -- A replaced cover must receive a different key or explicitly evict the previous key. -- Book removal and local-cache clearing must evict the associated decoded image where practical; stale entries remain bounded by Flutter's LRU limit if an explicit key is unavailable. - -## Proposed design - -### Stable provider - -Add a filesystem-backed `ImageProvider` for private covers. Its immutable key contains: - -- storage scope persistence key; -- cover bucket (`cached`, `pending`, or guest `books`); -- file identifier (media ID or book ID). - -The provider loads encoded bytes through the existing `BookImportService` APIs and decodes them through Flutter's image pipeline. Equal provider keys resolve through the same `ImageStreamCompleter`, allowing Flutter's global `ImageCache` to reuse decoded pixels across newly mounted widgets. - -Public HTTP covers continue using `CachedNetworkImage`; inline legacy data URIs keep their compatibility path. - -### Rendering - -`CoverImage` chooses one provider in the existing priority order: - -1. public or legacy inline cover; -2. authenticated cached cover by media ID; -3. authenticated pending cover by book ID; -4. guest permanent cover by book ID. - -For filesystem-backed covers, the widget layers the placeholder behind `Image(image: provider)`. The placeholder is visible only until the first successful decode. If the same provider key was already decoded, the cached image stream supplies the frame without another filesystem read or placeholder transition. - -### Cache ownership and limits - -Papyrus does not add a second raw-byte LRU. Flutter's `ImageCache` remains the session-memory owner and uses its configured LRU entry and byte limits. OPFS or native filesystem storage remains the persistent cache and offline source of truth. - -### Invalidation - -- Promotion changes identity from `pending/` to `cached/`; the already rendered frame remains visible while the new provider resolves. -- Replacing a cover produces a new media ID and therefore a new cache key. -- Switching server profile, account, or guest mode changes the scope component and cannot reuse another library's private image. -- Deleting a book or clearing local authenticated cover files evicts known provider keys when the caller has them. Flutter's LRU bounds any unreachable decoded entry that cannot be targeted directly. - -## Failure behavior - -- Missing local bytes produce the existing placeholder. -- A cached account cover that is absent locally continues through `MediaCacheService.ensureCoverCached`, which performs the authenticated lazy download and writes the result to local storage. -- Decode and filesystem errors remain local to the cover widget and do not break the surrounding library page. -- A failed replacement does not evict a previously decoded provider until the new provider has produced a frame. - -## Testing - -- Two separately mounted widgets with the same provider key invoke the filesystem loader once. -- Navigating from a library cover to a details cover with the same key reuses the cached image stream. -- Different account scopes and storage buckets do not share cache entries. -- Media-ID replacement uses a new provider key. -- Missing bytes and decode failures render the placeholder. -- Existing pending, guest, authenticated lazy-download, profile-switch, and metadata-regression tests remain green. - -## Rollout and rollback - -The change is client-only and requires no data migration. Rollout consists of the provider implementation, `CoverImage` integration, focused widget tests, and a browser navigation smoke test. Rollback restores the existing `FutureBuilder` renderer; persisted cover files and synchronized metadata remain compatible. - -## Open questions - -None for this iteration. Cover thumbnail generation and image-size optimization can be evaluated separately if profiling shows decode memory pressure. diff --git a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md deleted file mode 100644 index 4bcf2d2..0000000 --- a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md +++ /dev/null @@ -1,163 +0,0 @@ -# Media Storage Hardening Design - -## Context - -The media-storage pipeline spans the Papyrus Flutter client and FastAPI server. Book metadata is synchronized through PowerSync while book files and cover images remain private media assets stored outside the synchronized database. - -Guest and account libraries are intentionally separate. Signing in does not migrate or merge guest books, files, covers, or pending work into an account library. Guest covers remain permanent local media, while account covers use a local-first upload and cross-device cache lifecycle. - -## Goals - -- Persist authenticated cover images in platform-local files after their first successful display. -- Persist newly imported guest and account covers in platform-local files before book metadata is saved. -- Render private covers consistently across every book-cover surface. -- Isolate pending uploads and cached covers by selected server and authenticated user. -- Ensure only one client upload-queue processor runs at a time. -- Start upload processing after new tasks have been durably enqueued. -- Prevent concurrent server uploads from creating duplicate assets or exceeding quota. -- Restore a passing client CI suite with the intended `Download` and `Delete` labels. - -## Non-goals - -- Migrating or merging guest libraries into account libraries. -- Proactively downloading all covers during metadata synchronization. -- Storing cover bytes in SQLite, PowerSync tables, PostgreSQL, or SharedPreferences. -- Adding cross-server media transfer. -- Redesigning the existing media API. - -## Profile identity and isolation - -Authenticated local media state is scoped by two values: - -1. The selected sync-server profile key already produced by `SyncSettingsProvider.activeProfileKey`. -2. The authenticated server user ID. - -Together they form an immutable media scope for one server/account pair. Upload tasks, pending-cover paths, and cover-cache paths include both values. Signed-out and guest modes expose no authenticated upload scope and therefore no authenticated pending tasks. The single guest library uses a distinct local-only namespace for its permanent cover files. - -Switching server or account changes the active scope without deleting other scopes. Returning to the same server/account restores its pending uploads and cached covers. Clearing an authenticated cache explicitly removes the active scope's cover files; signing out alone retains them for offline reuse after a later sign-in to the same profile. - -## Filesystem cover cache - -### Storage layout - -Cover bytes are stored as opaque files; image decoding uses the bytes rather than a filename extension. - -- Native: the application-support directory contains `media-covers///cached/.bin` and `media-covers///pending/.bin`. -- Web: OPFS contains the equivalent directory hierarchy. -- Guest covers use the equivalent `media-covers/local/guest/books/.bin` layout and never enter an authenticated scope. - -Every write uses a temporary file or temporary OPFS entry followed by replacement so an interrupted write cannot leave a valid-looking partial cache entry. Cache path components are normalized before use, and callers cannot supply arbitrary paths. - -### Cache behavior - -A reusable cover loader follows this order: - -1. If the book has a non-empty public HTTP(S) `coverUrl`, render it through the existing network-image path. Legacy `data:` values remain readable for backward compatibility, but new imports and edits never create them. -2. If the book has `coverMediaId`, read the authenticated scoped cache file. -3. If the cached media file exists, render it immediately. -4. Otherwise download the authenticated media asset, persist it in the active scope, and render it. -5. If the book has no `coverMediaId`, look for a pending account cover or permanent guest cover derived from the book ID and active library mode. -6. If local read or download fails, render the existing placeholder without deleting book metadata. - -Downloads are coalesced by `(scope, mediaId)` so multiple widgets requesting the same cover share one operation. Completed futures are removed from the in-flight map; the persisted file, rather than a process-global future, is the long-lived cache. - -### UI integration - -A single reusable private-cover content widget owns the public-URL/private-media/placeholder decision. The existing sized `CoverImagePreview` composes it, and library cards, list rows, dashboard cards, shelf/topic sheets, context menus, and other book-cover surfaces use the same content widget. This prevents successful upload from clearing `coverUrl` and making covers disappear outside the details page. - -Book deletion removes pending, guest, and cached cover files for that book on a best-effort basis after pending upload tasks are removed. Clearing the active authenticated cache removes the whole scoped cover directory. Clearing authenticated data never removes guest book-file or cover storage. - -## Two-mode cover lifecycle - -### Fully offline guest library - -Import writes the extracted cover to the guest filesystem/OPFS namespace before saving book metadata. The book row contains no cover bytes or device-local path. Cover widgets derive the local cover key from the book ID and guest library mode. The cover remains local permanently unless the guest book is deleted. Signing in does not migrate it. - -### Account library and cross-device synchronization - -Account imports are local-first and remain usable without network connectivity: - -1. Write the extracted cover to the active server/user scope under `pending/.bin`. -2. Save and synchronize book metadata without cover bytes, a data URI, or a device-local path. -3. Persist a media upload task that references the pending cover key rather than embedding cover bytes. -4. After PowerSync has created the owned server book, upload the pending cover. -5. The server stores the private asset, updates `cover_media_id`, and exposes that portable ID through PowerSync. -6. Promote the source device's pending bytes into the scoped `.bin` cache and remove the pending file. -7. Other devices receive `cover_media_id`, lazily download the authenticated asset when first displayed, and persist their own scoped cache file. - -An account device that remains offline keeps its pending file and durable queue task until connectivity returns. Another device may show a placeholder before `cover_media_id` is available; it never receives a local path or raw cover bytes through synchronization. Concurrent replacements retain the server's existing last-committed replacement behavior. - -## Scoped upload queue - -`MediaUploadQueue` manages tasks per media scope instead of using one global `media_upload_queue` preference key. Each persisted key includes a normalized server profile and user ID. Task payloads do not duplicate the scope because they are stored under the scoped key. Cover tasks store only the book ID, pending-cover key, filename, content type, status, and error state; cover bytes remain in filesystem/OPFS and never enter SharedPreferences. - -Changing the active scope reloads that scope's tasks and usage state, then notifies the UI. Guest or signed-out state activates no scope and presents an empty authenticated queue. Cover task references remain in the scoped queue until upload succeeds; they never appear in another account's pending or failed list. - -Queue processing is single-flight. If processing is already active, additional calls return the same future and request one additional drain pass so a PowerSync completion signal cannot be lost behind an earlier `Book was not found` response. The operation captures its media scope, `AuthRepository`, and local file readers at start, so a later profile switch cannot redirect in-flight work to another server. Results are saved back to the captured scope. - -Enqueue and retry operations invoke a work-available callback only after the updated task list has been persisted. The application wires this callback to the existing media processor. This makes a newly imported book trigger its upload after enqueue, independently of PowerSync callback timing. PowerSync and authentication callbacks remain useful retry triggers and are safe because processing is single-flight. - -## Server concurrency and quota integrity - -The server serializes media uploads per user inside the database transaction: - -1. Lock the authenticated user's row with `SELECT ... FOR UPDATE`. -2. Lock and validate the target book row. -3. Recalculate the user's used bytes while holding the user lock. -4. Stream the upload into a temporary file while enforcing the remaining quota. -5. Replace the prior `(book_id, kind)` asset and update the book reference. -6. Commit before deleting the previous physical file. - -All uploads for one user therefore observe committed quota usage in order, including uploads for different books. Users do not block one another. - -The `media_assets` table also has a unique constraint on `(book_id, kind)` as a defensive invariant. The SQLAlchemy model and the existing unmerged Alembic revision are amended together. Because the revision has not shipped or merged, updating it avoids creating a corrective migration for a schema no deployment should have consumed. - -Rollback continues to remove temporary/new files and retain the previously committed asset. Physical-file deletion remains post-commit and best-effort. - -## CI label correction - -The mobile context-menu test asserts the product labels `Download` and `Delete`. No `book` suffix is added to the UI. - -## Error handling - -- Cover-cache failures degrade to a placeholder and remain retryable on a future build. -- A failed cache write does not hide successfully downloaded bytes during the current render, but it does not count as persisted. -- Queue network failures retain the task under its original scope. -- A missing server book leaves account media pending until a later PowerSync completion trigger retries it. -- A failed pending-cover read retains both the file reference and queue task. -- Storage-quota failures remain visible as failed tasks and require explicit retry. -- Profile switches never mutate or discard another scope's queue. -- Server constraint or transaction failures roll back database changes and remove the new temporary/final file. - -## Testing strategy - -All behavior changes follow red-green-refactor cycles. - -Client tests cover: - -- native/local cover-cache persistence, cache hits, atomic replacement, deletion, and scope isolation; -- web cover-store message handling and OPFS worker actions; -- guest cover persistence derived from book ID without SQL metadata bytes; -- account pending-cover persistence and restoration without SharedPreferences payload bytes; -- pending-cover promotion to `coverMediaId` cache after upload; -- cross-device behavior where a device with only `coverMediaId` lazily downloads its own cache; -- lazy download followed by a persisted cache hit; -- shared in-flight cover downloads; -- private-cover rendering in details and representative library/dashboard surfaces; -- upload task isolation across server/user scopes; -- restoration of tasks when returning to a scope; -- single-flight processing under overlapping triggers; -- a PowerSync retry trigger arriving during a failed media request causing an additional drain pass; -- enqueue-triggered processing after persistence; -- profile switching while processing without backend redirection; -- corrected `Download` and `Delete` context-menu expectations. - -Server tests cover: - -- the `(book_id, kind)` uniqueness metadata and migration definition; -- concurrent same-kind uploads producing one stored asset; -- concurrent uploads for different books respecting aggregate user quota; -- replacement rollback retaining the old physical file; -- the existing upload, download, delete, ownership, and sync-reference behavior. - -Final verification runs Flutter analysis and the full Flutter test suite, then Ruff, the full server pytest suite, Alembic single-head inspection, and migration upgrade/downgrade/upgrade against the test database when the repository runbook environment is available. diff --git a/docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md b/docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md deleted file mode 100644 index d3750ae..0000000 --- a/docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md +++ /dev/null @@ -1,40 +0,0 @@ -# Add Book Bottom Sheets Design - -## Goal - -Present both steps of the add-book flow as bottom sheets on every screen size and describe digital imports without emphasizing EPUB. - -## Presentation - -`AddBookChoiceSheet.show` and `ImportBookSheet.show` always use `showModalBottomSheet`. Remove their desktop-only dialog branches. - -- Use the root navigator so follow-up sheets appear above the application shell. -- Use the existing rounded top corners and standard bottom-sheet handle. -- Respect safe areas. -- Keep the choice sheet content-sized. -- Keep the import sheet content-sized in its idle state and scrollable when processing results, errors, or metadata previews increase its height. -- Do not reserve a fixed fraction of the viewport when the content does not require it. - -## Copy - -The digital import option and idle import state use format-neutral language: - -- Action: `Import digital books` -- Format list: `EPUB, PDF, AZW3, MOBI, CBZ/CBR` -- Prompt: `Select a digital book file` - -The offline-storage explanation and `Browse files` action remain unchanged. - -## Scope - -This change does not expand browser import support. The web file picker and importer continue accepting only EPUB until multi-format browser processing is implemented. Native import behavior is unchanged. - -## Verification - -Widget tests verify that: - -1. Both entry points use modal bottom sheets at desktop width. -2. Both sheets display the standard handle. -3. EPUB-specific prompts are absent. -4. The requested format list is visible. -5. The choice sheet still opens the import sheet and physical-book sheet correctly. diff --git a/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md b/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md deleted file mode 100644 index 0d47925..0000000 --- a/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md +++ /dev/null @@ -1,55 +0,0 @@ -# Book Edit Responsive Pane Design - -## Goal - -Use desktop space efficiently on the book edit page without allowing the cover pane or form fields to become cramped. Preserve the existing cover editing workflow and mobile layout. - -## Layout - -The desktop edit body uses the width allocated by its parent, excluding the application sidebar. - -- When the available content width can accommodate a 280 px supporting pane, the standard pane gap, page padding, and at least 420 px for the form, show the cover pane and form side by side. -- The cover pane has a fixed width of 280 px. The cover preview remains constrained and does not grow with the page. -- The form pane is flexible and consumes the remaining width, up to the page's existing maximum width. -- When less space is available, stack the cover pane above the form while retaining desktop-sized cover constraints. Do not switch to the mobile full-width cover merely because the panes stack. - -The breakpoint is derived from these minimum pane dimensions and evaluated with `LayoutBuilder`, rather than from the full browser width. - -## Metadata Lookup - -Metadata lookup belongs to the form rather than the cover pane because it updates multiple book fields, not only the cover. - -- Place `Fetch metadata` as the first form section on desktop. -- Give the search field the full section width so it is the primary control. -- Place a compact, always-visible source selector below the search field, preceded by the label `Source`. -- Keep the existing Open Library and Google Books selection behavior. -- Do not introduce a dropdown or combine the source selector into the search input. -- Keep error messages and search results below these controls. - -## Form Rows - -Rows containing two optional fields respond to the width of the form pane itself: - -- Display fields side by side when both receive a usable input width. -- Stack fields vertically when the form pane is too narrow. - -This behavior prevents field truncation while allowing the page-level panes to remain side by side at intermediate desktop widths. - -## Unchanged Behavior - -- Cover upload, URL entry, removal, preview, and persistence are unchanged. -- Metadata lookup behavior is unchanged. -- Form validation, saving, routing, and unsaved-change handling are unchanged. -- The existing mobile page layout is unchanged. -- The desktop header and Save action remain unchanged. - -## Verification - -Widget tests cover three states: - -1. Wide desktop: cover and form panes are side by side. -2. Intermediate desktop: panes remain side by side while paired form fields stack as necessary, with no render overflow. -3. Constrained desktop: the centered cover pane stacks above the form and the cover preview stays compact. -4. Desktop metadata lookup: the full-width search field precedes the compact source selector within the form pane. - -Static analysis must report no issues in the changed files. diff --git a/docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md b/docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md deleted file mode 100644 index bf5e54c..0000000 --- a/docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md +++ /dev/null @@ -1,35 +0,0 @@ -# Add Book Backdrop Continuity Design - -## Goal - -Prevent the modal backdrop from flashing when the add-book choice sheet transitions to the digital-book import sheet. - -## Root Cause - -The digital option currently pops the add-book choice `ModalBottomSheetRoute` and immediately pushes a new import `ModalBottomSheetRoute`. Each route owns a separate `ModalBarrier`, so the backdrop is removed and recreated while the two route animations overlap. The visible flash is a route-lifecycle artifact, not an import-content rendering issue. - -## Design - -Keep the existing add-book modal route mounted when the user selects **Import digital books**. The choice sheet will switch its body to the existing import content inside that route, preserving the same barrier and navigator overlay entry throughout the transition. - -`ImportBookSheet.show` will remain available for callers that open the import flow directly. Its content widget will be reusable by the choice sheet so import behavior, state, file handling, and layout stay in one implementation. - -The **Add physical book** path remains unchanged because it does not exhibit the reported flash and changing its large draggable form would expand the scope. - -## Transition Behavior - -- Opening **Add book** creates one modal route and one backdrop. -- Selecting **Import digital books** replaces only the sheet content; it does not pop or push a route. -- Canceling or dismissing the import content closes the existing modal route and returns to the library, matching current behavior. -- Direct calls to `ImportBookSheet.show` continue to open the same import UI in a modal bottom sheet. - -## Verification - -Add a widget regression test that opens the choice sheet, records the active modal barrier, selects **Import digital books**, and verifies: - -- the import content is displayed; -- exactly one modal barrier remains; -- the original barrier element remains mounted, proving it was not recreated; -- no additional popup route is pushed during the content transition. - -Run the focused add-book widget tests and static analysis for the modified files. diff --git a/docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md b/docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md deleted file mode 100644 index dabbb7d..0000000 --- a/docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md +++ /dev/null @@ -1,23 +0,0 @@ -# Import Action Button Shapes Design - -## Goal - -Give the paired actions in the successful digital-book import state one consistent pill shape. - -## Current Behavior - -`Pick different file` is an `OutlinedButton` that inherits Papyrus's 8px rounded-rectangle shape. `Add to library` is a `FilledButton` that inherits Material 3's pill shape because the application theme does not define a filled-button shape. Their equal placement makes the mismatch visually prominent. - -## Design - -Apply an explicit `StadiumBorder` to both success-state buttons: - -- `Pick different file` remains an outlined secondary action. -- `Add to library` remains a filled primary action. -- Both buttons retain their equal widths, current spacing, labels, enabled and disabled behavior, and callbacks. - -The shape override is local to the successful import action row. It does not change global button themes or introduce a shared component. - -## Verification - -Extend the add-book sheet widget tests to render the successful import state and verify that the outlined and filled actions both resolve to `StadiumBorder`. Run the focused add-book sheet tests and static analysis for the modified files. diff --git a/docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md b/docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md deleted file mode 100644 index 19cd408..0000000 --- a/docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md +++ /dev/null @@ -1,37 +0,0 @@ -# Import Add-to-Library Loading State Design - -## Goal - -Remove the distracting button-color pulse when a successfully imported digital book is added to the library, while providing clear progress feedback. - -## Root Cause - -Pressing **Add to library** sets `_committing` to true, which disables both actions and animates them into Material's disabled colors. The current `finally` block sets `_committing` back to false immediately before a successful sheet dismissal, so the buttons briefly animate toward their enabled colors again. The two state changes create the visible pulse. - -## Design - -During a commit: - -- Both actions remain disabled so duplicate commits and file changes are impossible. -- The outlined secondary action retains its normal foreground and border colors. -- The filled primary action retains its normal background and foreground colors. -- The primary button replaces `Add to library` with a compact progress indicator and the label `Adding...`. -- Button size, pill shape, spacing, and row layout remain unchanged. - -On success, `_committing` remains true until the modal sheet closes. This prevents the loading content from switching back before dismissal. On failure, `_committing` resets and the existing error state remains responsible for communicating the failure and retry action. - -## Accessibility - -Both buttons use `onPressed: null` during the commit, preserving correct disabled semantics for assistive technology. Progress is communicated with visible text as well as an indeterminate indicator, so it does not rely on color or animation alone. - -## Verification - -Extend the existing successful-import widget-test seam with an initial committing state. Verify that: - -- both action callbacks are disabled; -- the primary action shows `Adding...` and a progress indicator; -- the primary disabled colors resolve to the active primary colors; -- the secondary disabled foreground and border resolve to their active visual colors; -- `Add to library` is not displayed during the commit. - -Run the focused add-book sheet tests and static analysis for the modified files. diff --git a/docs/superpowers/specs/2026-07-24-acquisition-bottom-sheets-design.md b/docs/superpowers/specs/2026-07-24-acquisition-bottom-sheets-design.md deleted file mode 100644 index d57575f..0000000 --- a/docs/superpowers/specs/2026-07-24-acquisition-bottom-sheets-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# Acquisition Bottom Sheets Design - -## Goal - -Replace every dialog-style overlay in the acquisition experience with a bottom sheet so acquisition follows the interaction pattern used elsewhere in Papyrus. - -## Scope - -The change covers all overlays launched from the Acquisition page: - -- Add and edit integration -- Arr command selection -- Arr ID entry -- Remove-integration confirmation - -The Arr command selector is already a bottom sheet. It will retain that behavior and receive the same spacing and header treatment as the converted overlays. - -This change does not alter acquisition APIs, authentication, endpoint persistence, credential behavior, search, submission, or background-worker configuration. - -## Interaction Design - -### Integration Editor - -The integration editor opens as a modal bottom sheet at every window width. The existing tablet and desktop dialog path is removed. - -The sheet is: - -- sized to its content instead of being forced to fill the viewport; -- scroll controlled and safe-area aware; -- padded for the software keyboard; -- constrained to a maximum of 92 percent of the available height; -- width constrained by the app's Material bottom-sheet behavior on larger windows; -- bottom anchored with top-only rounded corners and a visible drag handle; -- dismissible by drag, backdrop tap, Back, or Cancel while idle; -- dynamically protected from barrier, drag, and back dismissal while a connection test or save is pending. - -The existing Integration and Connection groups, field validation, credential visibility controls, connection-test status, and footer actions remain unchanged. - -The editor reports its busy state to a focused route wrapper. The wrapper rebuilds the modal bottom-sheet route when that state changes so `isDismissible`, `enableDrag`, and the drag handle reflect the live operation state. This keeps standard sheet behavior while idle without allowing a pending mutation to disappear and skip the page reload. - -### Arr Command Selection - -The existing command sheet keeps its list-based interaction. It gains the shared Papyrus bottom-sheet handle and a clear header, while command rows retain their labels and supporting command names. - -### Arr ID Entry - -The ID dialog becomes a keyboard-aware form sheet. It contains: - -- the selected Arr command as the title; -- the existing comma-separated ID guidance; -- one ID text field; -- Cancel and Run actions. - -Run returns the same parsed integer list as today. Invalid entries continue to be ignored. - -### Remove Confirmation - -The remove dialog becomes a compact destructive-action sheet. It contains: - -- a shared sheet handle and title; -- the existing credential-removal warning; -- Cancel and Remove actions; -- destructive emphasis on Remove. - -The endpoint is deleted only after explicit confirmation. - -## Shared Presentation - -Sheets reuse the existing Papyrus `BottomSheetHandle` and `BottomSheetHeader` patterns where their action model fits. Layout uses the existing spacing, radius, color, and typography tokens. Every sheet is bottom anchored with top-only corners and content-driven height. Form sheets use keyboard insets and bounded scrolling so their actions remain reachable on small screens. - -No new dependency or app-wide overlay abstraction is introduced. Acquisition-specific helpers may be extracted when they remove duplication without changing other features. - -## State and Error Handling - -All existing callbacks and result values are preserved: - -- a saved editor returns `true` and reloads endpoints; -- cancelled sheets return `null` or `false` as appropriate; -- connection-test and save errors stay local to the editor; -- deletion errors continue to use the page snackbar; -- pending editor operations keep controls disabled and cannot be dismissed. - -## Accessibility - -Every sheet has a readable title. Icon-only actions retain tooltips or semantic labels. Destructive actions use explicit text. Keyboard focus, safe areas, text scaling, and scroll reachability are covered by the existing widget structure and regression tests. - -## Testing - -Widget tests will prove: - -- the integration editor uses a bottom sheet on both narrow and wide windows; -- short editor forms hug their content instead of occupying 92 percent of the viewport; -- larger forms and keyboard-open forms stop at the height cap and scroll; -- idle editor sheets expose drag, backdrop, and Back dismissal; -- pending editor operations dynamically disable those dismissal paths and restore them afterward; -- no acquisition editor dialog remains; -- Arr ID entry and remove confirmation use bottom sheets instead of dialogs; -- keyboard insets and constrained scrolling remain present for forms; -- Cancel, Run, Remove, Test connection, and Save preserve their results; -- busy integration operations still block dismissal; -- the focused acquisition suite, analyzer, formatter, and full Flutter suite pass. - -## Non-Goals - -- Refactoring dialogs elsewhere in Papyrus -- Changing acquisition endpoint types or capabilities -- Adding OPDS or other acquisition sources -- Enabling the background worker diff --git a/docs/superpowers/specs/2026-07-27-reader-integration-design.md b/docs/superpowers/specs/2026-07-27-reader-integration-design.md deleted file mode 100644 index c391ad5..0000000 --- a/docs/superpowers/specs/2026-07-27-reader-integration-design.md +++ /dev/null @@ -1,25 +0,0 @@ -# Reader Integration Design - -The Papyrus client will host the sibling `papyrus_reader` package without -exposing PowerSync, repositories, or application services to the package. - -The existing “Start reading” action will support EPUB and PDF files. It will -prepare the local media cache and navigate to a full-screen reader route. -Unsupported digital formats will produce a clear message and will not attempt -to open the reader. - -`ReaderPage` will resolve the book and cached bytes from client-owned services, -construct a `ReaderDocument`, restore a versioned `ReaderLocator` from -`Book.customMetadata`, and render `PapyrusReader`. Locator changes will be -debounced before updating the book in `DataStore`. The stored locator will -remain intact while summary fields such as current position, current page, -current CFI, reading status, and last-read time are updated for the rest of the -client. - -The client’s existing reading defaults will be mapped to `ReaderPreferences`. -Reader-owned preference changes remain local to the reader session for this -initial integration; global preference synchronization is outside this slice. - -Tests will cover format gating, locator restoration and persistence, preference -mapping, the stable reader route, and the Start reading action’s unsupported -format behavior. diff --git a/docs/superpowers/specs/2026-07-30-advanced-library-filters-visual-redesign-design.md b/docs/superpowers/specs/2026-07-30-advanced-library-filters-visual-redesign-design.md deleted file mode 100644 index db9c1b5..0000000 --- a/docs/superpowers/specs/2026-07-30-advanced-library-filters-visual-redesign-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Advanced Library Filters Visual Redesign - -## Goal - -Redesign the advanced library filter sheet without changing its filtering behavior. The sheet should feel like one coherent form instead of a stack of outlined cards, and desktop hover feedback should remain cleanly contained. - -## Visual Direction - -Use the web prototype as the structural reference while retaining Papyrus theme tokens. - -- Keep the existing narrow, bottom-centered sheet and fixed header and action bar. -- Preserve the existing header divider and action-bar top border. -- Remove subsection icons and generic card backgrounds. -- Distinguish Metadata, Organization, Reading, and Dates with uppercase labels, compact spacing, and a single divider between major sections. -- Hide option-based facets that have no available values. -- Use outlines to communicate expandable facets, inactive chips, and date controls. -- Reserve filled surfaces for search inputs and selected chips. -- Do not copy prototype shadows or color tints; derive colors from the active Papyrus theme. - -## Sheet Structure - -The sheet remains a single scrollable column between a fixed header and fixed action bar. - -- The header contains the drag handle, title, and close action. -- The scrolling body uses consistent horizontal padding and compact gaps between sections. -- The original header divider and action-bar top border remain unchanged. -- Reset, Cancel, and Show N books retain their current behavior. - -## Section Presentation - -Each section starts with a compact uppercase text label. Section labels have no icons or background block. Section labels, field labels, inputs, options, and chip boundaries follow one consistent left-alignment grid. A section contains only fields that have meaningful controls; unavailable option facets such as an empty Publishers or Series list are omitted. - -Sections retain their current order: - -1. Metadata -2. Organization -3. Reading -4. Dates - -## Searchable Facets - -Authors, Languages, Publishers, Series, Shelves, and Topics use a custom expandable facet instead of `Card` and `ExpansionTile`. - -- The expandable facet uses one rounded outline for its collapsed and expanded states. -- It shows the field label, either `Any` or the selected count, and a chevron. -- The entire row is clickable and keyboard accessible. -- Hover, focus, and pressed feedback is rendered by `Material` and `InkWell` using the same border radius, preventing rectangular or clipped state layers. -- A separator divides the expanded header from its content. -- Search inputs are compact, pill-shaped, surface-filled, and borderless at rest with a primary focus border. -- Matching options use transparent selectable rows with trailing checkboxes and separators contained within the facet outline. -- The options area shrink-wraps short lists and becomes scrollable only after a maximum height. -- A search with no matches shows a compact text state rather than an oversized empty panel. - -## Compact Controls - -Formats, reading statuses, ratings, and favorite state use compact choice chips. Selected values use the theme's selected-container colors; inactive chips are transparent with an `outlineVariant` border. - -Progress and date filters use plain labels without wrapper cards: - -- Progress keeps its enable switch, summary, and range slider in one compact left-aligned row. -- Date ranges keep their inclusive range behavior and clear action inside outlined rows. -- Labeled groups do not receive full-width wrapper backgrounds. - -## Interaction and State - -The redesign does not alter the draft filter model, matching logic, result preview, chip synchronization, or dismissal behavior. - -- Multiple searchable facets can remain expanded simultaneously. -- Reset clears the local draft. -- Close, Cancel, and backdrop dismissal discard the draft. -- Show N books applies the draft. -- Zero-result filters remain valid. - -## Accessibility - -- Preserve semantic labels for the sheet, filter fields, checkboxes, and actions. -- Keep at least 44 logical pixels for interactive rows and buttons. -- Expose expanded/collapsed state through the custom facet control. -- Use theme colors for sufficient contrast in light, dark, and e-ink modes. -- Do not rely on color alone to indicate selected options. - -## Scope - -Only the presentation and internal widget composition of the new advanced filter sheet are changed. Provider behavior, filter models, the search-bar badge, library chips, and shelf-content search remain functionally unchanged. - -## Verification - -No new automated tests are required. - -- Run targeted `flutter analyze` on the advanced filter feature files. -- Build and launch the Linux app. -- Inspect the sheet in dark mode at desktop width. -- Verify only purposeful outlines and separators remain: facets, date rows, inactive chips, option rows, and major section boundaries. -- Verify hover, focus, and pressed states follow rounded boundaries. -- Verify short option lists do not reserve excessive height. -- Verify empty option facets are hidden. -- Verify all filter interactions and draft/apply/reset behavior still work. diff --git a/docs/superpowers/specs/2026-08-01-advanced-filter-sheet-sizing-design.md b/docs/superpowers/specs/2026-08-01-advanced-filter-sheet-sizing-design.md deleted file mode 100644 index c030473..0000000 --- a/docs/superpowers/specs/2026-08-01-advanced-filter-sheet-sizing-design.md +++ /dev/null @@ -1,45 +0,0 @@ -# Advanced Filter Sheet Sizing Design - -## Overview - -Align the Advanced filters bottom sheet with Papyrus’s established bottom-sheet width and content spacing. - -## Problem - -The sheet applies a custom 760px desktop maximum width, making it visibly wider than standard sheets such as the book context menu. Its scrollable content and footer also use tighter horizontal insets than form sheets such as Add Shelf. - -## Goals - -- Match the standard Material bottom-sheet width used by the book context menu. -- Align the header, filter content, and footer actions to 24px horizontal insets using `Spacing.lg`. -- Preserve the current filter structure and interaction behavior. - -## Non-goals - -- Redesigning filter controls, typography, borders, sections, or colors. -- Changing draggable sizes, snapping, scrolling, preview counts, or filter semantics. -- Adding another custom desktop width. - -## Design - -- Remove the explicit 760px `showModalBottomSheet` constraint and allow the shared Material bottom-sheet defaults to determine width and bottom-center placement. -- Keep the transparent modal background and existing decorated sheet surface. -- Use `Spacing.lg` for the left and right padding of: - - the sheet header; - - the scrollable filter content; - - the sticky footer action bar. -- Preserve the header’s current vertical padding and give the close button enough internal room without reducing the right content inset. -- Preserve all current mobile behavior; the standard sheet width continues to use the available mobile width. - -## Verification - -- Compare desktop width against the book context menu bottom sheet. -- Confirm header title, section content, and footer actions share the same horizontal edges. -- Confirm no filter controls overflow at the narrower desktop width. -- Confirm mobile width, scrolling, snapping, close/cancel/reset/apply, and sticky footer behavior remain unchanged. -- Run targeted Flutter analysis and a debug web build. - -## Assumptions - -- The application’s Material bottom-sheet theme remains the source of truth for standard desktop width. -- Existing internal padding inside individual filter controls remains unchanged. diff --git a/docs/superpowers/specs/2026-08-01-book-import-workflow-rework-design.md b/docs/superpowers/specs/2026-08-01-book-import-workflow-rework-design.md deleted file mode 100644 index 62ac693..0000000 --- a/docs/superpowers/specs/2026-08-01-book-import-workflow-rework-design.md +++ /dev/null @@ -1,127 +0,0 @@ -# Book Import Workflow Rework Design - -## Overview - -Rework book import into distinct, consistently styled bottom sheets. Digital import becomes a confirmed multi-file workflow with per-book results, retry and removal controls, and one final library commit. Physical import adopts the same fixed-header and fixed-footer structure. - -## Problem - -The add-book method sheet currently replaces its own content with the digital import widget. This keeps the same modal route and mixes method selection, file selection, processing, preview, and commit state in one component. Digital import accepts only one file, and its actions scroll with the content. The physical form keeps its save action in the header rather than using the fixed footer established by Advanced filters. - -## Goals - -- Open digital and physical import as independent modal routes after the method sheet closes. -- Let users select and confirm multiple digital files in one operation. -- Present processing and commit results as removable book rows with explicit statuses. -- Support retrying failed files without reopening the workflow. -- Give digital selection, import results, and physical entry fixed headers and footers. -- Preserve the existing import, metadata extraction, storage, and commit services. - -## Non-goals - -- Background imports that survive navigation or application restarts. -- Import history or persistent import queues. -- Editing extracted digital-book metadata before commit. -- Changing supported file formats or the underlying metadata parsers. -- Adding online acquisition behavior to this workflow. - -## Component Design - -### AddBookChoiceSheet - -The method sheet remains selection-only. Its result enum gains a digital-import choice. After the choice route has fully completed, the caller opens either `DigitalBookImportSheet`, `AddPhysicalBookSheet`, or online search. The method sheet never swaps its own body. - -### AddBookSheetScaffold - -The three add-book sheets share a small layout component that renders: - -- a fixed drag handle and title/close header; -- a divider; -- one expanded scrollable body supplied by the sheet; -- a fixed footer with a top border and safe-area handling. - -Spacing, surface color, border treatment, and action placement match `LibraryAdvancedFilterSheet`. The shared component defines layout only; each sheet owns its actions and state. - -### DigitalBookImportSheet - -This sheet owns only file selection and confirmation. - -- The file picker uses multi-selection and reads file bytes. -- Supported extensions remain platform-specific: EPUB on web and the existing native format list elsewhere. -- The body initially presents a browse action, then a scrollable filename list. -- Every selected row can be removed before processing. -- Reopening the picker replaces the current selection. -- The footer contains Cancel and `Import N books`. -- The primary action is disabled until at least one readable file remains. - -Confirming closes this sheet and immediately opens `BookImportResultsSheet` with the selected files. - -### BookImportResultsSheet - -The results sheet starts processing when it opens. Its body is a scrollable list of one row per selected file. Each row includes the filename, extracted title and author when available, status, and contextual actions. - -Processing statuses are: - -- queued; -- processing; -- ready; -- failed. - -Ready rows may be removed before the final action. Failed rows provide Retry and Remove. A processing retry uses the original in-memory bytes and replaces the row’s prior error state. - -The fixed footer contains Cancel and `Add N to library`. The primary action is enabled only when processing has settled and at least one ready row remains. Its count includes only ready rows. - -During final commit, row actions and sheet dismissal are disabled. Commit states are adding, added, and failed. Ready rows are committed individually through `BookImportCommitService`. If every retained row is added, the sheet closes and reports the total added. If a commit fails, successfully added rows remain final, failed rows remain visible, and the sheet stays open so the user can retry that row’s commit or remove it without duplicating successful books. - -### AddPhysicalBookSheet - -The existing form and validation remain unchanged. The form becomes the scrollable body of the shared sheet scaffold. The fixed header contains the handle, title, and close action. The fixed footer contains Cancel and Add; Add uses the existing validation and save behavior. - -## Batch State - -Each selected file becomes a workflow-local immutable batch item containing: - -- a stable item ID; -- filename and optional bytes, allowing unreadable picker results to become failed rows; -- current processing or commit status; -- optional `BookImportResult`; -- optional user-safe error message. - -The workflow remains local to the results sheet and does not introduce provider-level or application-global state. Items process independently so the UI updates as each file finishes. - -## Cleanup and Dismissal - -- Removing a ready row deletes the temporary imported book file created by `BookImportService`. -- Retrying metadata processing starts from the original bytes; retrying a commit reuses its existing parsed result and temporary file. -- Cancelling, closing, or dismissing the results sheet deletes every successful-but-uncommitted temporary file. -- Added rows are never cleaned by sheet dismissal. -- Dismissal is blocked only while final commits are running. -- Failed parsing rows have no committed library record and retain their source bytes only until the sheet closes. - -## Error Handling - -File-read failures appear as failed rows rather than aborting the batch. Processing and commit errors are isolated to their rows. One failure never prevents other files from becoming ready or being added. Raw internal exceptions are converted to concise user-facing messages while remaining available to existing logging where applicable. - -## Verification - -Widget and model tests will verify: - -- digital selection opens on a new modal route after the method sheet dismisses; -- multi-file selection, confirmation counts, and pre-processing removal; -- fixed header, scrolling body, and fixed footer structure for all three sheets; -- independent queued, processing, ready, and failed states; -- retry and removal behavior; -- cleanup of successful-but-uncommitted temporary files; -- final commit of only retained ready rows; -- partial commit failure without duplicate additions; -- physical form validation and Add behavior from the footer. - -Run targeted Flutter tests during implementation, followed by `flutter analyze` and the complete Flutter test suite. - -## Assumptions - -- Selected file bytes may remain in memory for the lifetime of the results sheet. -- Processing can run concurrently through the existing import service. -- A fresh multi-file picker result replaces the digital selection draft. -- The final action adds every retained ready row; per-row inclusion is controlled through Remove. -- The current supported-format lists remain the source of truth. diff --git a/docs/superpowers/specs/2026-08-01-reusable-shelf-books-page-design.md b/docs/superpowers/specs/2026-08-01-reusable-shelf-books-page-design.md deleted file mode 100644 index d21b8b0..0000000 --- a/docs/superpowers/specs/2026-08-01-reusable-shelf-books-page-design.md +++ /dev/null @@ -1,164 +0,0 @@ -# Reusable Shelf Books Page Design - -- Status: Approved -- Date: 2026-08-01 -- Audience: Papyrus client engineers - -## Overview - -Replace the dedicated shelf-contents presentation with the Books page presentation configured for a shelf-scoped book collection. The shelf route must reuse the Books page search, filters, sorting, view modes, selection behavior, grids, lists, and responsive layout instead of maintaining parallel implementations. - -## Problem - -`ShelfContentsPage` currently owns separate search, filtering, sorting, grid/list rendering, selection headers, and responsive layouts. That implementation has already diverged from `LibraryPage` and requires every Books page improvement to be implemented twice. It also mixes child shelves with books, which conflicts with the faceted book-filtering model. - -## Goals - -- Use one Books page implementation for both the complete library and an individual shelf. -- Give every shelf page independent search, structured filters, sorting, view mode, and selection state. -- Restrict a shelf page to books assigned directly to that shelf. -- Derive quick-filter and advanced-filter choices from the shelf's complete, unfiltered book collection. -- Show an editable shelf identity containing its icon, color, name, and description. -- Replace the primary action with `Add to shelf`. -- Preserve the existing invalid-shelf experience. -- Remove obsolete shelf-book presentation state and logic from `ShelvesProvider`. - -## Non-goals - -- Implement adding books to a shelf. The `Add to shelf` action is intentionally an enabled no-op. -- Remove shelf hierarchy from models, persistence, shelf-management screens, or `DataStore`. -- Render child shelves on a shelf books page. -- Change the main Books page's behavior or filter-option population. -- Add or modernize automated tests. - -## Constraints - -- The existing uncommitted Shelves chip-alignment work must remain separate from this change. -- The shelf page must use a distinct `LibraryProvider`; filters from the main Books page must not carry into a shelf or back out of it. -- Values within one structured filter category retain OR behavior, and active categories retain AND behavior. -- Filter choices must remain stable while filters are applied. They are derived from the unfiltered shelf collection, not the current result set. -- Shelf membership remains the outer collection constraint and is applied before text search, structured filters, and sorting. - -## Proposed Design - -### Reusable Books Page - -Make `LibraryPage` accept an optional shelf collection configuration. With no shelf configuration, it retains its current main-library behavior. With shelf configuration, it receives the shelf identity, shelf name, scoped source books, primary-action presentation, and back-navigation behavior. - -The reusable page continues to own the responsive header, search bar, filter chips, advanced-filter sheet, result presentation, selection mode, bulk actions, and book navigation. Shelf mode changes only context-specific inputs, adds the shelf identity header, and excludes library-wide acquisition controls. - -### Shelf Route Adapter - -Reduce `ShelfContentsPage` to a thin route adapter that: - -1. Resolves the route's shelf ID through `DataStore`. -2. Shows the current `Shelf not found` state when the record is absent. -3. Owns and disposes a shelf-local `LibraryProvider`. -4. Obtains direct members through `DataStore.getBooksInShelf(shelf.id)`. -5. Renders `LibraryPage` with the shelf configuration and local provider. - -It must not instantiate `ShelvesProvider`, query child shelves, or render `ShelfCard` items. - -### Scoped Filter Options - -Extend `LibraryFilterOptions` so callers may provide an unfiltered source collection. The main Books page supplies all library books; shelf mode supplies direct shelf members. - -Metadata options are derived from those source books: - -- primary authors and co-authors; -- normalized languages; -- formats; -- publishers; -- series. - -Reading choices are also scoped: - -- reading statuses present among source books; -- integer ratings present among source books; -- Unrated only when at least one source book has no rating. - -The main Books page retains its current complete reading-status and rating choices. Only shelf mode narrows these choices to values present in its unfiltered direct members. - -Organization options are restricted to records attached to at least one source book: - -- topic IDs from each source book's tag relations; -- shelf IDs from each source book's shelf relations. - -Quick chips and the advanced sheet receive the same scoped options. Advanced-filter preview counts run `LibraryProvider.filterBooks` against the same source collection. Applied filters do not change the source used to construct options. - -### Shelf Collection Pipeline - -Shelf mode resolves visible books in this order: - -1. Read all books assigned directly to the shelf. -2. Apply plain-text search and the shelf-local `LibraryFilters` through `LibraryProvider.filterBooks`. -3. Apply the shelf-local sort through `LibraryProvider.sortBooks`. -4. Render using the shelf-local `LibraryViewMode`. - -The current library-wide acquisition placeholders, downloading-only chip, online results mode, and online-search empty-state action are omitted in shelf mode. Normal book selection and bulk book actions remain available. - -### Responsive Shelf Identity and Actions - -Shelf mode renders a flat identity row without a tinted card, shadow, or decorative background. The shelf's selected icon appears at the left in its selected color. Its name and description are stacked beside the icon, and an Edit icon button appears at the right. When the description is empty, the row shows a muted `Add a description` placeholder. - -The Edit action opens the existing `AddShelfSheet` in edit mode. The sheet continues to edit name, description, color, and icon, and saving updates the watched `DataStore` so every displayed value refreshes immediately. Saving an empty description must clear a previously stored description rather than preserving it through nullable `copyWith` behavior. - -On mobile, shelf mode places Back, the shelf identity, and Edit in one header row before the normal Books search and filter rows. On desktop, the identity row appears above the normal search/action row. - -The desktop `Add book` button becomes `Add to shelf`. The mobile FAB retains the Books page shape and plus icon with an `Add to shelf` tooltip and semantic label. Both controls use an empty callback so they remain visually enabled without changing data. - -### Empty and Missing States - -- An invalid or deleted shelf shows the existing `Shelf not found` state and Back-to-shelves action. -- A valid shelf with no direct books shows shelf-specific empty copy and the no-op `Add to shelf` action. -- A non-empty shelf reduced to zero results by search or filters shows the Books page no-results treatment without offering online search. - -## Interfaces and Dependencies - -- `LibraryPage` gains optional shelf-collection configuration while preserving its default constructor behavior for the main Books route. -- `LibraryFilterOptions.fromDataStore` gains an optional source-book collection. -- `LibraryFilterChips` gains scoped filter options or source books supplied by `LibraryPage`. -- `LibraryAdvancedFilterSheet.show` gains the source collection used for its options and preview count. -- `ShelfContentsPage` owns a local `LibraryProvider` and delegates rendering to `LibraryPage`. -- `ShelfContentsPage` opens the existing `AddShelfSheet` and persists all four editable shelf presentation fields through `DataStore`. -- The shelf update path must explicitly support clearing a nullable description while preserving omitted fields in unrelated `copyWith` calls. -- `DataStore.getBooksInShelf`, `getShelfIdsForBook`, and `getTagIdsForBook` remain the membership sources of truth. - -## Risks and Mitigations - -- **Main Books regressions from page parameterization:** keep all new configuration optional and preserve existing defaults; run targeted analysis and a web build. -- **Filters showing irrelevant values:** construct both quick and advanced options from the unfiltered shelf source. -- **Preview counts disagreeing with visible results:** pass the identical source collection to the advanced sheet and the page filtering pipeline. -- **State leaking between pages:** create and dispose a dedicated `LibraryProvider` in the shelf route adapter. -- **Stale shelf data after store updates:** resolve the shelf and its direct books from the watched `DataStore` on rebuild. -- **Empty descriptions cannot be persisted:** make clearing explicit in the shelf update path instead of relying on `description ?? this.description`. -- **Existing selected values disappear after membership changes:** the scoped provider is page-local, and the UI must tolerate selected values that are temporarily absent from refreshed options until filters are cleared. - -## Rollout and Rollback - -This is a client-only presentation refactor with no persisted-data migration. Roll out by replacing the shelf route implementation and removing only shelf-book-specific state from `ShelvesProvider`. Roll back by restoring the previous `ShelfContentsPage` and provider members; shelf models and stored relations remain compatible throughout. - -## Verification - -- Run targeted `flutter analyze` over the reusable page, shelf adapter, filter-option model, chips, advanced sheet, and providers. -- Run `flutter build web --debug`. -- Manually verify independent state between Books and shelf routes. -- Verify direct shelf membership only; child shelves never appear. -- Verify shelf-scoped option lists, preview counts, filtering, sorting, and all three view modes. -- Verify mobile and desktop identity layout with long names, present and absent descriptions, each shelf icon/color, and the enabled no-op `Add to shelf` actions. -- Verify editing name, description, color, and icon refreshes the header immediately, including clearing an existing description. -- Verify empty, no-results, missing-shelf, selection, bulk-action, and book-navigation states. - -## Accepted Decisions - -- Shelf pages use separate state from the main Books page. -- Filter choices are shelf-specific and derived from unfiltered direct shelf members. -- Child shelves are omitted only from the contents route; hierarchy remains elsewhere. -- The shelf identity is a flat row with a colored shelf icon, stacked name and description, and an Edit action. -- Shelf editing reuses `AddShelfSheet` and supports clearing descriptions. -- The reusable configurable `LibraryPage` approach is preferred over a second composed page or a larger shared-page extraction. -- No automated tests are added in this task. - -## Open Questions - -None. diff --git a/docs/superpowers/specs/2026-08-01-shelf-page-heading-design.md b/docs/superpowers/specs/2026-08-01-shelf-page-heading-design.md deleted file mode 100644 index e3dfaf2..0000000 --- a/docs/superpowers/specs/2026-08-01-shelf-page-heading-design.md +++ /dev/null @@ -1,69 +0,0 @@ -# Shelf Page Heading Design - -## Overview - -Refine the shelf books header so the shelf identity reads as a page heading rather than a compressed toolbar. The Books controls remain structurally unchanged below it. - -## Problem - -The current header places the back button, shelf icon, title, long description, and edit action in one horizontal row. On a wide viewport, the description stretches across the page and the edit icon becomes visually detached. The resulting row has weak hierarchy compared with the search field and chips below it. - -## Goals - -- Present the shelf name as the page title. -- Keep the shelf icon, configured color, description, and edit action visible. -- Constrain the description to a readable width and at most two lines. -- Align the title, description, search field, chips, and book grid consistently. -- Preserve the existing responsive Books-page controls and shelf-specific behavior. - -## Non-goals - -- Redesigning the search bar, filter chips, book grid, or Add to shelf action. -- Adding decorative cards, tinted header backgrounds, shadows, or a hero treatment. -- Changing shelf data, editing behavior, navigation, or filtering. - -## Proposed Design - -### Desktop - -- Use a dedicated heading block above the search-and-action row. -- Place the back button first, followed by the colored shelf icon and a content column. -- In the content column, place the shelf title and a compact Edit text action on the same line. -- Place the description beneath the title line. Align it with the title, constrain its width, allow at most two lines, and use the existing secondary text color. -- Keep the remainder of the heading row empty; do not push Edit to the far-right viewport edge. -- Preserve the search field and Add to shelf button on the following row, with the filter chips directly below. - -### Mobile - -- Keep the back button, colored shelf icon, title, and compact edit icon on the first line. -- Place the description beneath that line, aligned with the title rather than the back button. -- Allow at most two description lines with ellipsis overflow. -- Keep the full-width search field below the heading and retain the Add to shelf floating action button. - -### Visual Treatment - -- Increase the shelf title from toolbar-like typography to the existing page-heading scale. -- Render the shelf icon directly in its configured color, without a tinted container or decorative background. -- Use existing spacing, typography, color, and touch-target tokens. -- Keep the header compact: spacing should establish hierarchy without creating a large hero section. - -## Accessibility - -- Preserve tooltips and semantic labels for Back and Edit controls. -- Keep interactive targets at the application’s standard accessible size. -- Do not rely on shelf color alone; the shelf icon and title continue to identify the shelf. -- Ensure truncation remains usable with text scaling by retaining two description lines. - -## Verification - -- Confirm title, description, search, chips, and grid share a coherent left alignment on desktop. -- Confirm long descriptions wrap to two lines without pushing Edit away from the title. -- Confirm mobile layout does not overflow at narrow widths or increased text scale. -- Confirm missing descriptions still display the existing Add a description prompt. -- Confirm Back, Edit, search, filters, view controls, and Add to shelf retain their current behavior. -- Run targeted `flutter analyze` for the modified page. - -## Assumptions - -- The shelf identity remains visible outside book-selection mode and hidden during selection, matching current behavior. -- Only the reusable shelf variant of `LibraryPage` changes; the main Books header remains unchanged. diff --git a/docs/superpowers/specs/2026-08-01-shelves-page-controls-design.md b/docs/superpowers/specs/2026-08-01-shelves-page-controls-design.md deleted file mode 100644 index b21956e..0000000 --- a/docs/superpowers/specs/2026-08-01-shelves-page-controls-design.md +++ /dev/null @@ -1,107 +0,0 @@ -# Shelves Page Controls - -## Goal - -Give the Shelves page the same persistent search-and-chip control pattern as the Books page. Users can filter, sort, and change shelf density without controls moving between mobile and desktop layouts. - -## Interaction Model - -The search field remains the primary header control. A fixed-height, horizontally scrollable chip row sits immediately below it on every breakpoint. - -The chip order is: - -1. Contents -2. Sort -3. Type -4. View - -Active chips move before inactive chips while preserving their relative order, matching the Books page. An external `Clear all` action appears when any filter, non-default sort, or non-default view is active. Clearing restores Contents: All, Type: All, Sort: Name A–Z, and View: Small grid. Text search remains independent and is cleared only from the search field. - -The existing header sort button and view toggle are removed. The New shelf action keeps its current breakpoint-specific placement. - -## Controls - -### Contents - -- All -- With books -- Empty - -Book occupancy is calculated from the current `DataStore` shelf membership rather than cached display values. - -### Type - -- All -- Regular -- Smart - -Regular shelves match `isSmart == false`; Smart shelves match `isSmart == true`. - -### Sort - -- Name A–Z -- Name Z–A -- Book count: highest first -- Book count: lowest first -- Date created: newest first -- Date created: oldest first -- Date modified: newest first -- Date modified: oldest first - -The provider may retain its existing sort field plus ascending flag internally. The chip presents each field-direction combination as a single explicit option so selecting the current option never silently reverses it. - -### View - -- Small grid -- Large grid -- List - -Small grid preserves the current responsive density: 2 columns on phones, 4 on tablets, 5 on small desktops, and 6 on large desktops. Large grid uses 2 columns on phones, 3 on tablets and small desktops, and 4 on large desktops, following the Books grid pattern. List keeps the existing shelf list presentation. - -## State and Data Flow - -`ShelvesProvider` owns shelf search, contents filter, type filter, sort field and direction, and view mode. Its `shelves` getter applies operations in this order: - -1. Plain case-insensitive search over name and description. -2. Contents and type filters using AND logic. -3. Sorting. - -The page reads the resulting list once per build and passes it to the grid, list, count, and empty-state decisions. Chip selections notify once and immediately update the visible shelves. - -## Components - -Add a shelf-specific `ShelvesFilterChips` widget rather than generalizing the larger Books filter component. It uses the same visual language and modal selection-sheet interaction but exposes only shelf-specific options. This avoids coupling unrelated filter models and keeps the page focused. - -`ShelvesPage` uses one shared control structure across breakpoints: search row, chip row, then results. Mobile retains the navigation menu and floating New shelf action. Desktop retains its New shelf button. - -## Empty States - -When the complete shelf collection is empty, retain the existing creation-focused `No shelves yet` state and Create shelf action. - -When shelves exist but search or filters produce no results, show `No shelves found` with guidance to change search or filters. Do not present the Create shelf action as the primary resolution for a filtered result. - -## Accessibility - -- Chips expose category-specific semantic labels and selection state. -- Selection sheets have descriptive titles and indicate the selected option. -- Search clear, Clear all, New shelf, and view choices retain tooltips or semantic labels. -- The horizontally scrolling row remains keyboard and pointer accessible. -- Selected state is communicated by more than color. - -## Scope - -- These controls apply to the main Shelves page only. -- Shelf contents retain their existing book controls. -- Advanced shelf filtering and saved presets are out of scope. -- No new automated test files are required. - -## Verification - -- Run targeted `flutter analyze` for the provider, page, and new chip widget. -- Build the debug web application. -- Verify the same control order and spacing on mobile and desktop. -- Verify every search, filter, sort, and view combination. -- Verify Small grid, Large grid, and List at each responsive breakpoint. -- Verify Clear all resets structured controls without clearing text search. -- Verify empty-library and no-results states remain distinct. -- Verify active chips move first without changing the chip-row height. diff --git a/docs/superpowers/specs/2026-08-23-book-import-drop-zone-design.md b/docs/superpowers/specs/2026-08-23-book-import-drop-zone-design.md deleted file mode 100644 index 42569a3..0000000 --- a/docs/superpowers/specs/2026-08-23-book-import-drop-zone-design.md +++ /dev/null @@ -1,60 +0,0 @@ -# Book Import Drop Zone Design - -## Goal - -Replace the current compact purple file-picker panel with a responsive Material 3 drop zone that fills the available sheet body, supports real file dropping on web and desktop, and retains picker-based selection everywhere. - -## Visual Design - -The empty-state body contains one large drop zone inset by `Spacing.lg` on every side. It expands to fill the space between the sheet header and footer and uses the same structure on mobile and desktop. - -At rest, the drop zone has no colored fill. A rounded dashed border uses `colorScheme.outlineVariant`, with a 16px-equivalent application radius. Centered content contains: - -1. A primary-colored upload icon. -2. A `titleMedium` instruction. -3. Supported formats in `bodyMedium` using `onSurfaceVariant`. -4. An outlined **Browse files** button with a folder/upload icon. - -Desktop and web show **Drag and drop book files here**. Android and iOS show **Choose book files**. Both show **EPUB, PDF, MOBI, AZW3, TXT, CBR, and CBZ** and the same **Browse files** action. - -Hover and keyboard focus add a subtle neutral state layer. While files are dragged over the target, the dashed border and icon change to `primary` and the background receives a faint `primaryContainer` tint. Picking state replaces the icon with a progress indicator and disables repeated activation. - -## Interaction and Data Flow - -`desktop_drop` supplies drop-entered, drop-exited, and drop-completed events on web, Windows, macOS, and Linux. Mobile keeps the identical visual structure but uses the picker only. - -Dropped entries are filtered by the same supported extension list as the picker. Supported files are read asynchronously into `SelectedBookFile` values and replace the drop zone with the existing selected-file list. The existing reset, remove, cancel, and import behavior remains unchanged. - -Unsupported-only drops leave the drop zone visible and show **No supported book files were dropped.** If a mixed drop contains supported and unsupported entries, supported files are selected and an inline warning explains that unsupported files were skipped. Supported files that cannot be read appear in the existing unreadable-file state. - -The controller receives dropped selections through a small public command rather than depending on `desktop_drop`; platform file conversion stays at the widget boundary. - -## Components - -- `BookImportSelectingSection` continues to choose between the empty drop zone and selected-file list. -- A focused stateful drop-zone widget owns hover, focus, drag-active, and file-reading presentation state. -- A small custom painter draws the dashed rounded border, avoiding a second visual dependency. -- `BookImportController` accepts dropped selections and optional picker/drop feedback using the same selection state already used by browsing. - -## Accessibility - -The entire drop zone remains tappable/clickable, exposes button semantics, supports keyboard activation, and retains a visible focused state. The explicit **Browse files** button avoids relying on drag-and-drop discovery. State changes use both border and background/icon changes rather than color alone. - -## Testing - -Add focused widget/controller coverage for: - -- the drop zone filling the available body; -- desktop and mobile instruction copy; -- drag-active visual state; -- supported dropped files replacing the empty state; -- unsupported drops showing inline feedback; -- picker behavior and the existing selected-file list remaining intact. - -Run formatting, whole-app analysis, and the add-book widget test suite. - -## Non-goals - -- Changing the selected-file cards or footer layout. -- Keeping the large drop zone visible after selection. -- Changing import processing, persistence, supported formats, or concurrency. diff --git a/docs/superpowers/specs/2026-08-23-book-import-sheet-refactor-design.md b/docs/superpowers/specs/2026-08-23-book-import-sheet-refactor-design.md deleted file mode 100644 index 1851dc5..0000000 --- a/docs/superpowers/specs/2026-08-23-book-import-sheet-refactor-design.md +++ /dev/null @@ -1,63 +0,0 @@ -# Book Import Sheet Refactor Design - -## Goal - -Simplify `book_import_sheet.dart` by separating import orchestration from presentation and removing repeated item-card presentation code. Preserve the existing user-visible behavior, import concurrency, web-worker integration, error messages, cleanup guarantees, and the current uncommitted `BrowseArea` sizing change. - -## Scope - -This is a behavior-preserving refactor. It does not change `BookImportService`, `book_worker.js`, supported formats, persistence, or the number of imports processed concurrently. - -The refactor will produce four focused units: - -1. `book_import_sheet.dart` remains the public entry point. It owns modal/provider wiring, creates and disposes the controller, renders controller state, and handles navigation and snackbars. -2. `book_import_controller.dart` owns selected files, batch items, phase transitions, processing tokens and in-flight futures, retry behavior, commit behavior, temporary-file cleanup, and close coordination. -3. `book_import_sheet_sections.dart` contains the selecting, processing, and summary layouts plus selection-only presentation. -4. `book_import_item_card.dart` contains shared import-item presentation used by processing and summary rows, including title, subtitle, cover, fallback icon, status, and actions. - -## Controller Contract - -`BookImportController` receives the existing injected operations: file picker, processor, committer, deleter, and optional completion callback. It exposes read-only state and commands for browsing, clearing or removing selections, starting imports, retrying or removing batch items, and requesting close. - -The controller uses `ChangeNotifier` so the sheet can rebuild from one state owner. It guards notifications after disposal. Commands that can fail in a way requiring UI feedback return a small result value; the sheet remains responsible for snackbars and navigation because those require `BuildContext`. - -The existing safety behavior remains intact: - -- Imports start concurrently. -- Per-item tokens prevent stale processing results from overwriting newer state. -- In-flight processing and cleanup operations are deduplicated. -- Closing waits for processing and removes temporary files for uncommitted items. -- Parse retries repeat parse and commit; commit retries repeat only commit. -- Completion is emitted at most once after all items settle. - -## Presentation - -The sheet listens to the controller and selects one of the three phase widgets. Phase widgets receive immutable values and callbacks rather than accessing controller internals directly. - -Processing and summary cards use a shared item presentation model derived from `BookImportBatchItem`. Differences such as progress indicators, success/failure styling, retry actions, and remove actions remain configurable without duplicating title, subtitle, cover, and fallback-icon logic. - -No labels, button availability, layout behavior, or modal sizing will intentionally change. The existing `MainAxisSize.max` change in `_BrowseArea` will be preserved. - -## Error Handling and Lifecycle - -Processor and committer exceptions continue to become safe per-item messages. Picker failures remain inline. Cleanup failures continue to block the destructive action and produce the same snackbar. Controller disposal prevents late async completions from notifying a dead widget while allowing already-started cleanup work to finish safely. - -## Tests and Verification - -Add focused controller tests covering: - -- successful parse and commit through the summary phase; -- processing failure followed by retry; -- commit failure followed by commit-only retry; -- close waiting for active processing and cleaning uncommitted temporary files; -- deduplicated cleanup and single completion notification. - -Add a lightweight widget test covering selection, processing, and summary rendering through injected callbacks. Run Dart formatting, targeted tests, and Flutter analysis for all changed files. - -## Non-goals - -- Performance changes or new scheduling rules. -- Web-worker changes. -- New import formats. -- Visual redesign. -- Changes to repository or persistence behavior. diff --git a/docs/superpowers/specs/2026-08-23-book-storage-status-design.md b/docs/superpowers/specs/2026-08-23-book-storage-status-design.md deleted file mode 100644 index 1c5c9c2..0000000 --- a/docs/superpowers/specs/2026-08-23-book-storage-status-design.md +++ /dev/null @@ -1,203 +0,0 @@ -# Per-book account and device status design - -## Problem - -Papyrus is local-first. A book can remain visible after a reload because it is -stored in the browser's local PowerSync database even when the backend and file -storage services were unavailable. The current global sync card does not make -that distinction clear and can display stale connection information. Users -therefore cannot tell whether a book is safely stored in their account or -whether its file is available on the current device. - -The runtime investigation for this design confirmed that a reported book had -reached the backend and file storage after services returned, while the client -still displayed `Offline` and `No completed sync yet`. The design must make -per-book state explicit and prevent older asynchronous global status updates -from overwriting newer ones. - -## Goals - -- Show whether each authenticated book is syncing, saved to the account, or in - a failed sync state. -- Show local digital-file availability without confusing it with account sync. -- Keep book-card navigation unchanged: tapping a card opens book details. -- Download a missing book file only when the user chooses to start reading. -- Derive states from existing local-first queues and confirmed server data. -- Keep local availability probes lightweight on web and native platforms. - -## Non-goals - -- Polling a new backend status endpoint. -- Uploading or downloading files merely to calculate status. -- Requiring cover upload to finish before a book is considered saved. -- Applying local-file status to physical books. -- Redesigning the library card beyond the new state treatments. - -## State model - -`BookStorageStatusController` exposes two independent states for every book. - -### Account state - -`BookAccountStatus` has three values: - -- `syncing`: required account work is pending and remains retryable. -- `saved`: all required data has been acknowledged by the server. -- `failed`: required account work has failed in a way that needs user action. - -Guest books do not have an account state and do not show an account badge. - -For authenticated physical books: - -- A pending metadata write means `syncing`. -- A definitive metadata failure means `failed`. -- Acknowledged metadata means `saved`. - -For authenticated digital books: - -- A pending metadata write or pending book-file upload means `syncing`. -- A definitive metadata or book-file failure means `failed`. -- The book is `saved` only when its metadata is acknowledged and the local - PowerSync row contains a server-confirmed, non-empty `fileMediaId`. - -Cover uploads are excluded from this calculation. Network loss and temporary -service outages remain `syncing` because the queues retry them automatically. -`failed` is reserved for actionable conditions such as rejected metadata or -exhausted file-storage quota. - -### Device state - -`BookDeviceStatus` has three values: - -- `checking`: local availability has not yet been established. -- `available`: the current device has the digital book file. -- `missing`: the current device does not have the digital book file. - -Physical books do not receive a device state. While a digital book is being -checked, its card keeps the normal appearance to prevent a gray flash during -list loading. - -## Architecture - -### PowerSync service boundary - -`PapyrusPowerSyncService` exposes per-book metadata state without leaking the -`ps_crud` table or PowerSync transaction details into widgets. - -- On authenticated database activation, it reconstructs pending book IDs from - the PowerSync write queue. -- A local book insert or update marks that book pending immediately. -- The connector reports the affected book IDs when an upload transaction is - accepted and completed. -- Acknowledgement clears their pending metadata state. -- A definitive upload rejection records a per-book failure. Transient - connection errors leave the books pending. -- Changes are exposed as a stream or listenable map keyed by book ID. - -The global `SyncState` adapter must also serialize status calculations or use a -generation guard. An older asynchronous pending-write query may not publish -after a newer PowerSync status has already been observed. - -### Combined per-book controller - -`BookStorageStatusController` combines: - -- the current account/guest mode; -- per-book PowerSync metadata state; -- `MediaUploadQueue` tasks for `MediaKind.bookFile`; -- each book's server-confirmed `fileMediaId`; and -- the local availability probe. - -This keeps card and details-page rules in one testable component. Widgets -receive resolved display states rather than reproducing business rules. - -### Lightweight local availability - -The existing local file reader returns the full file, which is inappropriate -for checking every visible card. Add `hasBookFile(bookId)` alongside the -existing read/store/delete operations: - -- Web asks the existing book worker whether the OPFS entry exists and does not - transfer file bytes to the UI isolate. -- Native platforms use a filesystem existence check and do not open the file. - -Availability results are cached by book ID. Import completion, successful -download, local deletion, cache clearing, and account/profile changes -invalidate the relevant entries. - -## Library-card presentation - -The existing format badge remains at the cover's bottom-left. Authenticated -books receive a compact account badge at bottom-right: - -- `Saved` with `cloud_done_outlined`. -- `Syncing…` with `sync`. -- `Sync failed` with `sync_problem_outlined`. - -The badge uses icon and text so color is not the sole indicator. It is -informational and not a separate touch target. Desktop provides a tooltip, and -the card's semantic label includes the full account state. - -When a digital book is confirmed missing locally: - -- its cover is desaturated; -- its card surface receives a subtle neutral tint; -- title and author retain accessible contrast; and -- its semantic label includes `Not available on this device`. - -The whole card must not be faded with reduced opacity. Physical books and -digital books in the `checking` or `available` states retain the normal visual -treatment. - -## Details page and reader launch - -Tapping any library card continues to open the book details page. No download -starts during card navigation. - -The reading action behaves as follows: - -- Local file available: show `Start reading` and open the reader normally. -- Local file missing and book saved to account: show `Download and read`. -- Download active: disable the action, show `Downloading…`, and provide - an indeterminate progress indicator. Byte-level progress is outside this - scope because the existing download API returns the completed byte buffer. -- Download failure: retain the details page and show an inline error with a - `Try again` action. -- Local file missing while account state is `syncing`: disable reading and - explain that the book file is still being saved to the account. -- Local file missing while account state is `failed`: disable reading and - direct the user to retry the failed sync/upload. - -`Download and read` uses the existing `MediaCacheService.ensureBookFileCached` -path. After a successful cache write, it invalidates device status, resolves it -as `available`, and opens the reader. If the device is offline, the inline -failure message explains that the file is not stored locally and a connection -is required. - -## Error and recovery behavior - -- Temporary backend, PowerSync, or storage unavailability leaves affected - books in `syncing`; automatic retries continue. -- A file-storage quota rejection produces `failed` and exposes the existing - retry/recovery path. -- A definitive metadata rejection produces `failed` for every affected book in - that upload transaction. -- A failed local availability probe is not treated as `missing`; it remains - unresolved and may be retried to avoid falsely graying the card. -- Status text must not expose credentials, private endpoints, or raw exception - strings. - -## Validation - -Testing remains focused on the new state boundaries: - -- Unit-test the physical and digital account-state truth table. -- Unit-test transient versus definitive failures. -- Unit-test local availability caching and invalidation. -- Test web OPFS and native existence checks without reading full files. -- Widget-test all three badges, guest behavior, and the missing-local tint. -- Widget-test details-page reading states and download retry behavior. -- Regression-test that superseded global status calculations cannot replace a - newer connected/synced state. - -No broad test-suite expansion or unrelated library-card refactor is included.