diff --git a/app/lib/data/sample_data.dart b/app/lib/data/sample_data.dart index e92cbac..84bdee2 100644 --- a/app/lib/data/sample_data.dart +++ b/app/lib/data/sample_data.dart @@ -10,6 +10,7 @@ import 'package:papyrus/models/reading_session.dart'; import 'package:papyrus/models/series.dart'; import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; /// Comprehensive sample data for development and testing. class SampleData { @@ -40,7 +41,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1401432508i/4099.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 264, currentPosition: 0.75, isFavorite: true, @@ -64,7 +65,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1436202607i/3735293.jpg', fileFormat: BookFormat.pdf, - readingStatus: ReadingStatus.completed, + readingStatus: LibraryReadingStatus.completed, currentPage: 464, currentPosition: 1.0, isFavorite: true, @@ -88,7 +89,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1555447414i/44767458.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 310, currentPosition: 0.45, seriesId: 'series-1', @@ -112,7 +113,7 @@ class SampleData { 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/61439040.jpg', isPhysical: true, physicalLocation: 'Bookshelf A, Row 2', - readingStatus: ReadingStatus.completed, + readingStatus: LibraryReadingStatus.completed, currentPage: 328, currentPosition: 1.0, rating: 4, @@ -137,7 +138,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1348027904i/85009.jpg', fileFormat: BookFormat.pdf, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 83, currentPosition: 0.2, addedAt: _daysAgo(45), @@ -158,7 +159,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1546071216i/5907.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, isFavorite: true, seriesId: 'series-2', @@ -180,7 +181,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1386925632i/44936.jpg', fileFormat: BookFormat.mobi, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 269, currentPosition: 0.6, addedAt: _daysAgo(60), @@ -202,7 +203,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1703329310i/23692271.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 394, currentPosition: 0.85, isFavorite: true, @@ -225,7 +226,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1554437249i/6088007.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, addedAt: _daysAgo(14), ), @@ -244,7 +245,7 @@ class SampleData { 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1655988385i/40121378.jpg', isPhysical: true, physicalLocation: 'Bookshelf B, Row 1', - readingStatus: ReadingStatus.completed, + readingStatus: LibraryReadingStatus.completed, currentPage: 320, currentPosition: 1.0, isFavorite: true, @@ -269,7 +270,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1344692678i/11724436.jpg', fileFormat: BookFormat.pdf, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 144, currentPosition: 0.3, addedAt: _daysAgo(90), @@ -290,7 +291,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1417900846i/29579.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, seriesId: 'series-3', seriesNumber: 1, @@ -310,7 +311,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1472680751i/44492285.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, seriesId: 'series-1', seriesNumber: 2, @@ -330,7 +331,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1564063404i/44492286.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, seriesId: 'series-1', seriesNumber: 3, @@ -350,7 +351,7 @@ class SampleData { coverUrl: 'https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1546071217i/15241.jpg', fileFormat: BookFormat.epub, - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, seriesId: 'series-2', seriesNumber: 2, diff --git a/app/lib/models/book.dart b/app/lib/models/book.dart index ea83902..c93b7e4 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -1,38 +1,4 @@ -/// Reading status of a book. -enum ReadingStatus { notStarted, inProgress, completed, paused, abandoned } - -/// Extension to get display labels for reading status. -extension ReadingStatusExtension on ReadingStatus { - String get label { - switch (this) { - case ReadingStatus.notStarted: - return 'Not started'; - case ReadingStatus.inProgress: - return 'Reading'; - case ReadingStatus.completed: - return 'Completed'; - case ReadingStatus.paused: - return 'Paused'; - case ReadingStatus.abandoned: - return 'Abandoned'; - } - } - - String get shortLabel { - switch (this) { - case ReadingStatus.notStarted: - return 'Not started'; - case ReadingStatus.inProgress: - return 'Reading'; - case ReadingStatus.completed: - return 'Finished'; - case ReadingStatus.paused: - return 'Paused'; - case ReadingStatus.abandoned: - return 'DNF'; - } - } -} +import 'package:papyrus/providers/enums/library_reading_status.dart'; /// Format of the book file. enum BookFormat { epub, pdf, mobi, azw3, txt, cbr, cbz } @@ -90,7 +56,7 @@ class Book { final DateTime? lentAt; // Reading state - final ReadingStatus readingStatus; + final LibraryReadingStatus readingStatus; final int? currentPage; final double currentPosition; // 0.0 to 1.0 final String? currentCfi; // EPUB CFI position @@ -135,7 +101,7 @@ class Book { this.physicalLocation, this.lentTo, this.lentAt, - this.readingStatus = ReadingStatus.notStarted, + this.readingStatus = LibraryReadingStatus.unread, this.currentPage, this.currentPosition = 0.0, this.currentCfi, @@ -169,12 +135,6 @@ class Book { /// Progress as a percentage string. String get progressLabel => '$progressPercent%'; - /// Whether the book is currently being read. - bool get isReading => readingStatus == ReadingStatus.inProgress; - - /// Whether the book has been finished. - bool get isFinished => readingStatus == ReadingStatus.completed; - /// Whether the book has any progress. bool get hasProgress => currentPosition > 0; @@ -230,7 +190,7 @@ class Book { bool clearLentTo = false, DateTime? lentAt, bool clearLentAt = false, - ReadingStatus? readingStatus, + LibraryReadingStatus? readingStatus, int? currentPage, double? currentPosition, String? currentCfi, @@ -358,7 +318,7 @@ class Book { physicalLocation: json['physical_location'] as String?, lentTo: json['lent_to'] as String?, lentAt: json['lent_at'] != null ? DateTime.parse(json['lent_at'] as String) : null, - readingStatus: ReadingStatus.values.byName(json['reading_status'] as String? ?? 'notStarted'), + readingStatus: LibraryReadingStatus.values.byName(json['reading_status'] as String? ?? 'unread'), currentPage: json['current_page'] as int?, currentPosition: (json['current_position'] as num?)?.toDouble() ?? 0.0, currentCfi: json['current_cfi'] as String?, diff --git a/app/lib/models/library_filter_options.dart b/app/lib/models/library_filter_options.dart new file mode 100644 index 0000000..77f3347 --- /dev/null +++ b/app/lib/models/library_filter_options.dart @@ -0,0 +1,135 @@ +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/utils/book_language.dart'; + +class LibraryFilterOption { + final T value; + final String label; + + const LibraryFilterOption({required this.value, required this.label}); +} + +class LibraryFilterOptions { + final List> authors; + final List> languages; + final List> formats; + final List> topics; + final List> shelves; + final List> publishers; + final List> series; + final List> readingStatuses; + final List ratings; + final bool hasUnrated; + + const LibraryFilterOptions({ + required this.authors, + required this.languages, + required this.formats, + required this.topics, + required this.shelves, + required this.publishers, + required this.series, + required this.readingStatuses, + required this.ratings, + required this.hasUnrated, + }); + + factory LibraryFilterOptions.fromDataStore(DataStore dataStore, {Iterable? books}) { + final isScoped = books != null; + final sourceBooks = books ?? dataStore.books; + final authors = {}; + final languages = {}; + final formats = {}; + final publishers = {}; + final series = {}; + final sourceBookIds = {}; + final topicIds = {}; + final shelfIds = {}; + final readingStatuses = {}; + final ratings = {}; + var hasUnrated = false; + + for (final book in sourceBooks) { + sourceBookIds.add(book.id); + 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); + readingStatuses.add(book.readingStatus); + + final rating = book.rating; + if (rating == null) { + hasUnrated = true; + } else { + ratings.add(rating); + } + } + + if (isScoped) { + for (final relation in dataStore.bookTagRelations) { + if (sourceBookIds.contains(relation.bookId)) { + topicIds.add(relation.tagId); + } + } + + for (final relation in dataStore.bookShelfRelations) { + if (sourceBookIds.contains(relation.bookId)) { + shelfIds.add(relation.shelfId); + } + } + } + + return LibraryFilterOptions( + authors: _stringOptions(authors), + languages: _stringOptions(languages), + formats: _stringOptions(formats), + topics: _sortedOptions( + dataStore.tags + .where((topic) => !isScoped || topicIds.contains(topic.id)) + .map((topic) => LibraryFilterOption(value: topic.id, label: topic.name)), + ), + shelves: _sortedOptions( + dataStore.shelves + .where((shelf) => !isScoped || shelfIds.contains(shelf.id)) + .map((shelf) => LibraryFilterOption(value: shelf.id, label: shelf.name)), + ), + publishers: _stringOptions(publishers), + series: _stringOptions(series), + 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, + ); + } + + static void _addNormalized(Map labelsByValue, String? value) { + final label = value?.trim(); + if (label == null || label.isEmpty) { + return; + } + + labelsByValue.putIfAbsent(label.toLowerCase(), () => label); + } + + static List> _stringOptions(Map labelsByValue) { + return _sortedOptions( + labelsByValue.entries.map((entry) => LibraryFilterOption(value: entry.key, label: entry.value)), + ); + } + + static List> _sortedOptions(Iterable> options) { + return options.toList()..sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); + } +} diff --git a/app/lib/models/library_filters.dart b/app/lib/models/library_filters.dart new file mode 100644 index 0000000..056ac4b --- /dev/null +++ b/app/lib/models/library_filters.dart @@ -0,0 +1,206 @@ +import 'package:flutter/foundation.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; + +enum FavoriteFilter { any, favorites, notFavorites } + +@immutable +class LibraryProgressRange { + final double start; + final double end; + + const LibraryProgressRange(this.start, this.end) : assert(start >= 0), assert(end <= 1), assert(start <= end); + + bool contains(double progress) => progress >= start && progress <= end; + + @override + bool operator ==(Object other) { + return other is LibraryProgressRange && other.start == start && other.end == end; + } + + @override + int get hashCode => Object.hash(start, end); +} + +@immutable +class LibraryDateRange { + final DateTime start; + final DateTime end; + + LibraryDateRange(DateTime start, DateTime end) + : start = _dateOnly(start), + end = _dateOnly(end), + assert(!_dateOnly(end).isBefore(_dateOnly(start))); + + bool contains(DateTime? value) { + if (value == null) { + return false; + } + + final date = _dateOnly(value); + return !date.isBefore(start) && !date.isAfter(end); + } + + static DateTime _dateOnly(DateTime value) { + final localValue = value.toLocal(); + return DateTime(localValue.year, localValue.month, localValue.day); + } + + @override + bool operator ==(Object other) { + return other is LibraryDateRange && other.start == start && other.end == end; + } + + @override + int get hashCode => Object.hash(start, end); +} + +@immutable +class LibraryFilters { + static const _notProvided = Object(); + + final Set authors; + final Set languages; + final Set formats; + final Set topicIds; + final Set shelfIds; + final Set publishers; + final Set seriesNames; + final Set statuses; + final FavoriteFilter favoriteFilter; + final LibraryProgressRange? progressRange; + final Set ratings; + final bool includeUnrated; + final LibraryDateRange? publicationDateRange; + final LibraryDateRange? dateAddedRange; + final LibraryDateRange? lastReadDateRange; + + LibraryFilters({ + Set authors = const {}, + Set languages = const {}, + Set formats = const {}, + Set topicIds = const {}, + Set shelfIds = const {}, + Set publishers = const {}, + Set seriesNames = const {}, + Set statuses = const {}, + this.favoriteFilter = FavoriteFilter.any, + this.progressRange, + Set ratings = const {}, + this.includeUnrated = false, + this.publicationDateRange, + this.dateAddedRange, + this.lastReadDateRange, + }) : authors = Set.unmodifiable(authors), + languages = Set.unmodifiable(languages), + formats = Set.unmodifiable(formats), + topicIds = Set.unmodifiable(topicIds), + shelfIds = Set.unmodifiable(shelfIds), + publishers = Set.unmodifiable(publishers), + seriesNames = Set.unmodifiable(seriesNames), + statuses = Set.unmodifiable(statuses), + ratings = Set.unmodifiable(ratings); + + int get activeCategoryCount { + var count = 0; + if (authors.isNotEmpty) count++; + if (languages.isNotEmpty) count++; + if (formats.isNotEmpty) count++; + if (topicIds.isNotEmpty) count++; + if (shelfIds.isNotEmpty) count++; + if (publishers.isNotEmpty) count++; + if (seriesNames.isNotEmpty) count++; + if (statuses.isNotEmpty) count++; + if (favoriteFilter != FavoriteFilter.any) count++; + if (progressRange != null) count++; + if (ratings.isNotEmpty || includeUnrated) count++; + if (publicationDateRange != null) count++; + if (dateAddedRange != null) count++; + if (lastReadDateRange != null) count++; + return count; + } + + bool get isEmpty => activeCategoryCount == 0; + + LibraryFilters copyWith({ + Set? authors, + Set? languages, + Set? formats, + Set? topicIds, + Set? shelfIds, + Set? publishers, + Set? seriesNames, + Set? statuses, + FavoriteFilter? favoriteFilter, + Object? progressRange = _notProvided, + Set? ratings, + bool? includeUnrated, + Object? publicationDateRange = _notProvided, + Object? dateAddedRange = _notProvided, + Object? lastReadDateRange = _notProvided, + }) { + return LibraryFilters( + authors: authors ?? this.authors, + languages: languages ?? this.languages, + formats: formats ?? this.formats, + topicIds: topicIds ?? this.topicIds, + shelfIds: shelfIds ?? this.shelfIds, + publishers: publishers ?? this.publishers, + seriesNames: seriesNames ?? this.seriesNames, + statuses: statuses ?? this.statuses, + favoriteFilter: favoriteFilter ?? this.favoriteFilter, + progressRange: identical(progressRange, _notProvided) + ? this.progressRange + : progressRange as LibraryProgressRange?, + ratings: ratings ?? this.ratings, + includeUnrated: includeUnrated ?? this.includeUnrated, + publicationDateRange: identical(publicationDateRange, _notProvided) + ? this.publicationDateRange + : publicationDateRange as LibraryDateRange?, + dateAddedRange: identical(dateAddedRange, _notProvided) + ? this.dateAddedRange + : dateAddedRange as LibraryDateRange?, + lastReadDateRange: identical(lastReadDateRange, _notProvided) + ? this.lastReadDateRange + : lastReadDateRange as LibraryDateRange?, + ); + } + + @override + bool operator ==(Object other) { + return other is LibraryFilters && + setEquals(other.authors, authors) && + setEquals(other.languages, languages) && + setEquals(other.formats, formats) && + setEquals(other.topicIds, topicIds) && + setEquals(other.shelfIds, shelfIds) && + setEquals(other.publishers, publishers) && + setEquals(other.seriesNames, seriesNames) && + setEquals(other.statuses, statuses) && + other.favoriteFilter == favoriteFilter && + other.progressRange == progressRange && + setEquals(other.ratings, ratings) && + other.includeUnrated == includeUnrated && + other.publicationDateRange == publicationDateRange && + other.dateAddedRange == dateAddedRange && + other.lastReadDateRange == lastReadDateRange; + } + + @override + int get hashCode => Object.hash( + Object.hashAllUnordered(authors), + Object.hashAllUnordered(languages), + Object.hashAllUnordered(formats), + Object.hashAllUnordered(topicIds), + Object.hashAllUnordered(shelfIds), + Object.hashAllUnordered(publishers), + Object.hashAllUnordered(seriesNames), + Object.hashAllUnordered(statuses), + favoriteFilter, + progressRange, + Object.hashAllUnordered(ratings), + includeUnrated, + publicationDateRange, + dateAddedRange, + lastReadDateRange, + ); +} diff --git a/app/lib/models/search_filter.dart b/app/lib/models/search_filter.dart index a2be0b4..385973b 100644 --- a/app/lib/models/search_filter.dart +++ b/app/lib/models/search_filter.dart @@ -1,5 +1,6 @@ import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; /// Represents a search filter condition. class SearchFilter { @@ -47,9 +48,9 @@ class SearchFilter { case SearchField.topic: return dataStore?.getTagsForBook(book.id).map((t) => t.name).join(',') ?? book.topics.join(','); case SearchField.status: - if (book.isFinished) return 'finished'; - if (book.isReading) return 'reading'; - if (book.progress == 0) return 'unread'; + if (book.readingStatus == LibraryReadingStatus.completed) return 'finished'; + if (book.readingStatus == LibraryReadingStatus.inProgress) return 'reading'; + if (book.readingStatus == LibraryReadingStatus.unread) return 'unread'; return 'reading'; case SearchField.progress: return (book.progress * 100).round().toString(); diff --git a/app/lib/models/shelf.dart b/app/lib/models/shelf.dart index 8211176..229f7e7 100644 --- a/app/lib/models/shelf.dart +++ b/app/lib/models/shelf.dart @@ -72,6 +72,7 @@ class Shelf { String? id, String? name, String? description, + bool clearDescription = false, String? colorHex, IconData? icon, String? parentShelfId, @@ -86,7 +87,7 @@ class Shelf { return Shelf( id: id ?? this.id, name: name ?? this.name, - description: description ?? this.description, + description: clearDescription ? null : description ?? this.description, colorHex: colorHex ?? this.colorHex, icon: icon ?? this.icon, parentShelfId: parentShelfId ?? this.parentShelfId, diff --git a/app/lib/pages/library_page.dart b/app/lib/pages/library_page.dart index e9b7377..23f2b6d 100644 --- a/app/lib/pages/library_page.dart +++ b/app/lib/pages/library_page.dart @@ -5,15 +5,14 @@ import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/models/active_filter.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/library_filter_options.dart'; +import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/providers/acquisition_downloads_provider.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/bulk_book_actions.dart'; -import 'package:papyrus/utils/search_query_parser.dart'; -import 'package:papyrus/widgets/filter/filter_bottom_sheet.dart'; -import 'package:papyrus/widgets/filter/filter_dialog.dart'; import 'package:papyrus/widgets/library/acquisition_confirmation_dialog.dart'; import 'package:papyrus/widgets/library/book_grid.dart'; import 'package:papyrus/widgets/library/book_list_item.dart'; @@ -21,6 +20,7 @@ import 'package:papyrus/widgets/library/acquisition_job_sheets.dart'; import 'package:papyrus/widgets/library/acquisition_job_visibility.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_list_item.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; +import 'package:papyrus/widgets/library/library_advanced_filter_sheet.dart'; import 'package:papyrus/widgets/library/library_filter_chips.dart'; import 'package:papyrus/widgets/library/online_books_header.dart'; import 'package:papyrus/widgets/library/online_results_view.dart'; @@ -29,14 +29,19 @@ import 'package:papyrus/widgets/search/library_search_bar.dart'; import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; -import 'package:papyrus/widgets/shared/view_mode_toggle.dart'; import 'package:provider/provider.dart'; /// Main library page with responsive layouts for all platforms. /// - Mobile: AppBar with search, filter chips, 2-column grid, FAB /// - Desktop: Header row, filter chips, 5-column grid or list view class LibraryPage extends StatefulWidget { - const LibraryPage({super.key}); + final Shelf? shelf; + final VoidCallback? onBack; + final VoidCallback? onEditShelf; + + const LibraryPage({super.key, this.shelf, this.onBack, this.onEditShelf}); + + bool get isShelfView => shelf != null; @override State createState() => _LibraryPageState(); @@ -53,12 +58,12 @@ class _AcquisitionLibraryView { } class _LibraryPageState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - AcquisitionDownloadsProvider? _visibleDownloadsProvider; - _BooksPresentationMode _presentationMode = _BooksPresentationMode.local; - bool _showDownloadingOnly = false; int _presentationGeneration = 0; + bool _showDownloadingOnly = false; + AcquisitionDownloadsProvider? _visibleDownloadsProvider; late final TextEditingController _onlineSearchController; + final GlobalKey _scaffoldKey = GlobalKey(); + _BooksPresentationMode _presentationMode = _BooksPresentationMode.local; @override void initState() { @@ -69,8 +74,29 @@ class _LibraryPageState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final provider = context.read(); + final provider = widget.isShelfView ? null : context.read(); + + _updateVisibleDownloadsProvider(provider); + } + @override + void didUpdateWidget(LibraryPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isShelfView == widget.isShelfView) { + return; + } + + final provider = widget.isShelfView ? null : context.read(); + _updateVisibleDownloadsProvider(provider); + + if (widget.isShelfView) { + _presentationMode = _BooksPresentationMode.local; + _showDownloadingOnly = false; + } + } + + void _updateVisibleDownloadsProvider(AcquisitionDownloadsProvider? provider) { if (!identical(provider, _visibleDownloadsProvider)) { _presentationGeneration += 1; _visibleDownloadsProvider?.setLibraryVisible(false); @@ -89,59 +115,51 @@ class _LibraryPageState extends State { @override Widget build(BuildContext context) { final libraryProvider = context.watch(); - final downloadsProvider = context.watch(); + final downloadsProvider = widget.isShelfView ? null : context.watch(); final dataStore = context.watch(); final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - // Get filtered books from DataStore (single source of truth) - final books = _getFilteredBooks(libraryProvider, dataStore); + final sourceBooks = _sourceBooks(dataStore); + final filterOptions = _filterOptions(dataStore, sourceBooks); + final books = _getFilteredBooks(libraryProvider, dataStore, sourceBooks); final isLoading = !dataStore.isLoaded; if (isDesktop) { - return _buildDesktopLayout(context, books, dataStore, libraryProvider, downloadsProvider, isLoading); + return _buildDesktopLayout( + context, + books, + sourceBooks, + filterOptions, + libraryProvider, + downloadsProvider, + isLoading, + ); } - return _buildMobileLayout(context, books, dataStore, libraryProvider, downloadsProvider, isLoading); + return _buildMobileLayout( + context, + books, + sourceBooks, + filterOptions, + libraryProvider, + downloadsProvider, + isLoading, + ); } - List _getFilteredBooks(LibraryProvider provider, DataStore dataStore) { - var books = dataStore.books; - - // Apply search filter using query parser - if (provider.searchQuery.isNotEmpty) { - final searchQuery = SearchQueryParser.parse(provider.searchQuery); - if (searchQuery.isNotEmpty) { - books = books.where((book) => searchQuery.matches(book, dataStore: dataStore)).toList(); - } - } + List _sourceBooks(DataStore dataStore) { + final shelf = widget.shelf; + return shelf == null ? dataStore.books : dataStore.getBooksInShelf(shelf.id); + } - // Apply category filters (quick filters from chips) - if (!provider.isFilterActive(LibraryFilterType.all)) { - if (provider.isFilterActive(LibraryFilterType.favorites)) { - books = books.where((book) => provider.isBookFavorite(book.id, book.isFavorite)).toList(); - } - if (provider.isFilterActive(LibraryFilterType.reading)) { - books = books.where((book) => book.isReading).toList(); - } - if (provider.isFilterActive(LibraryFilterType.finished)) { - books = books.where((book) => book.isFinished).toList(); - } - if (provider.isFilterActive(LibraryFilterType.unread)) { - books = books.where((book) => book.readingStatus == ReadingStatus.notStarted).toList(); - } - if (provider.isFilterActive(LibraryFilterType.shelves) && provider.selectedShelf != null) { - books = books - .where((book) => dataStore.getShelvesForBook(book.id).any((s) => s.name == provider.selectedShelf)) - .toList(); - } - if (provider.isFilterActive(LibraryFilterType.topics) && provider.selectedTopic != null) { - books = books - .where((book) => dataStore.getTagsForBook(book.id).any((t) => t.name == provider.selectedTopic)) - .toList(); - } - } + LibraryFilterOptions _filterOptions(DataStore dataStore, List sourceBooks) { + return widget.isShelfView + ? LibraryFilterOptions.fromDataStore(dataStore, books: sourceBooks) + : LibraryFilterOptions.fromDataStore(dataStore); + } + List _getFilteredBooks(LibraryProvider provider, DataStore dataStore, List sourceBooks) { + final books = provider.filterBooks(sourceBooks, dataStore: dataStore); return provider.sortBooks(books); } @@ -152,21 +170,28 @@ class _LibraryPageState extends State { Widget _buildMobileLayout( BuildContext context, List books, - DataStore dataStore, + List sourceBooks, + LibraryFilterOptions filterOptions, LibraryProvider libraryProvider, AcquisitionDownloadsProvider? downloadsProvider, bool isLoading, ) { - final isOnline = _presentationMode == _BooksPresentationMode.online && downloadsProvider != null; + final isOnline = + !widget.isShelfView && _presentationMode == _BooksPresentationMode.online && downloadsProvider != null; final isBookSelection = libraryProvider.isSelectionMode; - final localItems = buildAcquisitionLibraryItems(books: dataStore.books, jobs: downloadsProvider?.jobs ?? const []); + final localItems = buildAcquisitionLibraryItems( + books: sourceBooks, + jobs: widget.isShelfView ? const [] : downloadsProvider?.jobs ?? const [], + ); final showDownloadingOnly = _showDownloadingOnly && localItems.hasDownloadingItems; + final acquisitionView = _buildAcquisitionLibraryView( books: books, items: localItems, libraryProvider: libraryProvider, showDownloadingOnly: showDownloadingOnly, ); + final selectedJobs = _visibleSelectedJobs(downloadsProvider, acquisitionView.selectableJobs); final hasJobSelection = selectedJobs.isNotEmpty; final hideLocalControls = isOnline || hasJobSelection || isBookSelection; @@ -175,11 +200,10 @@ class _LibraryPageState extends State { return Scaffold( key: _scaffoldKey, - drawer: const LibraryDrawer(), + drawer: widget.isShelfView ? null : const LibraryDrawer(), body: SafeArea( child: Column( children: [ - // Header: selection header or normal header Padding( padding: const EdgeInsets.only(top: Spacing.md, left: Spacing.md, right: Spacing.md), child: isOnline @@ -198,64 +222,47 @@ class _LibraryPageState extends State { onSelectAll: () => libraryProvider.selectAll(books.map((b) => b.id).toList()), onDeselectAll: libraryProvider.deselectAll, ) - : Row( - children: [ - // Drawer hamburger button - IconButton( - icon: const Icon(Icons.menu), - onPressed: () { - _scaffoldKey.currentState?.openDrawer(); - }, - tooltip: 'Library sections', - ), - const SizedBox(width: Spacing.xs), - // Search bar - Expanded(child: _buildSearchBar(libraryProvider)), - const SizedBox(width: Spacing.sm), - _buildSortButton(libraryProvider), - ], - ), + : _buildMobileLocalHeader(libraryProvider), ), - // Quick filter chips if (!isOnline) - LibraryFilterChips( - showDownloading: localItems.hasDownloadingItems, - isDownloadingSelected: showDownloadingOnly, - onDownloadingTapped: () => setState(() => _showDownloadingOnly = !_showDownloadingOnly), - onLibraryFilterTapped: () { - if (_showDownloadingOnly) { - setState(() => _showDownloadingOnly = false); - } - }, + Column( + children: [ + SizedBox(height: Spacing.sm), + LibraryFilterChips( + filterOptions: filterOptions, + showDownloading: !widget.isShelfView && localItems.hasDownloadingItems, + isDownloadingSelected: showDownloadingOnly, + onDownloadingTapped: () => setState(() => _showDownloadingOnly = !_showDownloadingOnly), + onLibraryFilterTapped: () { + if (_showDownloadingOnly) { + setState(() => _showDownloadingOnly = false); + } + }, + ), + ], ), - // View toggle row - if (!hideLocalControls) - Padding( - padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - isLoading ? 'Loading books…' : '${books.length} ${books.length == 1 ? 'book' : 'books'}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - _buildViewToggle(libraryProvider), - ], - ), + Expanded( + child: _buildBookContent( + context, + libraryProvider, + downloadsProvider, + isLoading, + acquisitionView, + sourceBooks.length, ), - - // Book grid or list - Expanded(child: _buildBookContent(context, libraryProvider, downloadsProvider, isLoading, acquisitionView)), + ), ], ), ), floatingActionButton: hideLocalControls ? null - : FloatingActionButton(onPressed: () => _showAddBook(downloadsProvider), child: const Icon(Icons.add)), + : FloatingActionButton( + onPressed: widget.isShelfView ? () {} : () => _showAddBook(downloadsProvider), + tooltip: widget.isShelfView ? 'Add to shelf' : null, + child: const Icon(Icons.add), + ), bottomNavigationBar: isOnline && downloadsProvider.selectedReleaseTokens.isNotEmpty ? _buildMobileOnlineAction(downloadsProvider) : hasJobSelection @@ -266,165 +273,35 @@ class _LibraryPageState extends State { ); } - /// Build list of active filters for display. - /// Uses a Set to prevent duplicate filters. - /// Quick filters (Reading, Favorites, Finished) are excluded because they - /// are already shown as highlighted buttons in the QuickFilterChips bar. - List _buildActiveFilters(LibraryProvider provider) { - final filters = {}; - - if (provider.selectedShelf != null) { - filters.add( - ActiveFilter( - type: ActiveFilterType.query, - label: 'shelf', - value: provider.selectedShelf!, - queryString: 'shelf:"${provider.selectedShelf}"', - ), - ); - } - if (provider.selectedTopic != null) { - filters.add( - ActiveFilter( - type: ActiveFilterType.query, - label: 'topic', - value: provider.selectedTopic!, - queryString: 'topic:"${provider.selectedTopic}"', - ), + Widget _buildMobileLocalHeader(LibraryProvider libraryProvider) { + if (!widget.isShelfView) { + return Row( + children: [ + IconButton( + icon: const Icon(Icons.menu), + onPressed: () => _scaffoldKey.currentState?.openDrawer(), + tooltip: 'Library sections', + ), + const SizedBox(width: Spacing.xs), + Expanded(child: _buildSearchBar(libraryProvider)), + ], ); } - // Parse search query into individual filters - // Skip shelf/topic if already added from provider to avoid duplicates - if (provider.searchQuery.isNotEmpty && provider.searchQuery.contains(':')) { - final query = SearchQueryParser.parse(provider.searchQuery); - for (final filter in query.filters) { - if (filter.field.name != 'any') { - // Skip if this field is already represented by provider selection - if (filter.field.name == 'shelf' && provider.selectedShelf != null) { - continue; - } - if (filter.field.name == 'topic' && provider.selectedTopic != null) { - continue; - } - filters.add( - ActiveFilter( - type: ActiveFilterType.query, - label: filter.field.name, - value: filter.value, - queryString: '${filter.field.name}:${filter.value}', - ), - ); - } - } - } - - return filters.toList(); - } - - /// Show the filter bottom sheet. - Future _showFilterBottomSheet(BuildContext context) async { - final libraryProvider = context.read(); - final dataStore = context.read(); - final filterOptions = FilterOptions.fromBooks( - dataStore.books, - shelfNames: dataStore.shelves.map((s) => s.name).toList(), - topicNames: dataStore.tags.map((t) => t.name).toList(), - ); - - final result = await FilterBottomSheet.show( - context, - filterOptions: filterOptions, - initialFilters: AppliedFilters.fromQueryString( - libraryProvider.searchQuery, - filterReading: libraryProvider.isFilterActive(LibraryFilterType.reading), - filterFavorites: libraryProvider.isFilterActive(LibraryFilterType.favorites), - filterFinished: libraryProvider.isFilterActive(LibraryFilterType.finished), - filterUnread: libraryProvider.isFilterActive(LibraryFilterType.unread), - shelf: libraryProvider.selectedShelf, - topic: libraryProvider.selectedTopic, - ), - ); - - if (result != null) { - _applyFilterResult(result); - } - } - - /// Show the filter dialog (desktop). - Future _showFilterDialog(BuildContext context) async { - final libraryProvider = context.read(); - final dataStore = context.read(); - final filterOptions = FilterOptions.fromBooks( - dataStore.books, - shelfNames: dataStore.shelves.map((s) => s.name).toList(), - topicNames: dataStore.tags.map((t) => t.name).toList(), - ); - - final result = await FilterDialog.show( - context, - filterOptions: filterOptions, - initialFilters: AppliedFilters.fromQueryString( - libraryProvider.searchQuery, - filterReading: libraryProvider.isFilterActive(LibraryFilterType.reading), - filterFavorites: libraryProvider.isFilterActive(LibraryFilterType.favorites), - filterFinished: libraryProvider.isFilterActive(LibraryFilterType.finished), - filterUnread: libraryProvider.isFilterActive(LibraryFilterType.unread), - shelf: libraryProvider.selectedShelf, - topic: libraryProvider.selectedTopic, - ), + return Column( + children: [ + _buildShelfIdentity(context, showBack: true, compact: true), + const SizedBox(height: Spacing.md), + SizedBox(width: double.infinity, child: _buildSearchBar(libraryProvider)), + ], ); - if (result != null) { - _applyFilterResult(result); - } - } - - /// Apply the filter result from either the bottom sheet or dialog. - void _applyFilterResult(AppliedFilters result) { - final libraryProvider = context.read(); - - // Apply quick filters - if (result.filterReading) { - libraryProvider.addFilter(LibraryFilterType.reading); - } else { - libraryProvider.removeFilter(LibraryFilterType.reading); - } - if (result.filterFavorites) { - libraryProvider.addFilter(LibraryFilterType.favorites); - } else { - libraryProvider.removeFilter(LibraryFilterType.favorites); - } - if (result.filterFinished) { - libraryProvider.addFilter(LibraryFilterType.finished); - } else { - libraryProvider.removeFilter(LibraryFilterType.finished); - } - if (result.filterUnread) { - libraryProvider.addFilter(LibraryFilterType.unread); - } else { - libraryProvider.removeFilter(LibraryFilterType.unread); - } - - // Apply shelf/topic - libraryProvider.selectShelf(result.shelf); - libraryProvider.selectTopic(result.topic); - - // Set or clear search query from advanced filters - final queryString = result.toQueryString(); - if (queryString.isNotEmpty) { - libraryProvider.setSearchQuery(queryString); - } else { - libraryProvider.clearSearch(); - } } Widget _buildSearchBar(LibraryProvider libraryProvider) { - final activeFilters = _buildActiveFilters(libraryProvider); - final isDesktop = MediaQuery.of(context).size.width >= Breakpoints.desktopSmall; - return LibrarySearchBar( initialQuery: libraryProvider.searchQuery, - activeFilterCount: activeFilters.length, + activeFilterCount: libraryProvider.activeFilterCount, + onFilterTap: _showAdvancedFilters, onQueryChanged: (query) { if (query.isEmpty) { libraryProvider.clearSearch(); @@ -432,10 +309,26 @@ class _LibraryPageState extends State { libraryProvider.setSearchQuery(query); } }, - onFilterTap: () => isDesktop ? _showFilterDialog(context) : _showFilterBottomSheet(context), ); } + Future _showAdvancedFilters() async { + final libraryProvider = context.read(); + final dataStore = context.read(); + final sourceBooks = _sourceBooks(dataStore); + final filters = await LibraryAdvancedFilterSheet.show( + context, + libraryProvider: libraryProvider, + dataStore: dataStore, + sourceBooks: sourceBooks, + filterOptions: _filterOptions(dataStore, sourceBooks), + ); + + if (filters != null && mounted) { + libraryProvider.applyFilters(filters); + } + } + void _enterOnlineMode( AcquisitionDownloadsProvider provider, { String initialQuery = '', @@ -691,76 +584,23 @@ class _LibraryPageState extends State { // DESKTOP LAYOUT // ============================================================================ - Widget _buildViewToggle(LibraryProvider libraryProvider) { - return ViewModeToggle( - isGridView: libraryProvider.isGridView, - onChanged: (isGrid) => libraryProvider.setViewMode(isGrid ? LibraryViewMode.grid : LibraryViewMode.list), - ); - } - - Widget _buildAddBookButton(double height, AcquisitionDownloadsProvider? downloadsProvider) { - return FilledButton.icon( - onPressed: () => _showAddBook(downloadsProvider), - icon: const Icon(Icons.add), - label: const Text('Add book'), - style: FilledButton.styleFrom(minimumSize: Size(0, height)), - ); - } - - Widget _buildSortButton(LibraryProvider provider) { - return PopupMenuButton( - icon: const Icon(Icons.sort), - tooltip: 'Sort books', - onSelected: provider.setSortOption, - itemBuilder: (context) => [ - _buildSortMenuItem(LibrarySortOption.dateAddedNewest, 'Date added (newest)', provider.sortOption), - _buildSortMenuItem(LibrarySortOption.dateAddedOldest, 'Date added (oldest)', provider.sortOption), - const PopupMenuDivider(), - _buildSortMenuItem(LibrarySortOption.titleAZ, 'Title (A\u2013Z)', provider.sortOption), - _buildSortMenuItem(LibrarySortOption.titleZA, 'Title (Z\u2013A)', provider.sortOption), - const PopupMenuDivider(), - _buildSortMenuItem(LibrarySortOption.authorAZ, 'Author (A\u2013Z)', provider.sortOption), - _buildSortMenuItem(LibrarySortOption.authorZA, 'Author (Z\u2013A)', provider.sortOption), - const PopupMenuDivider(), - _buildSortMenuItem(LibrarySortOption.lastRead, 'Last read', provider.sortOption), - _buildSortMenuItem(LibrarySortOption.rating, 'Rating', provider.sortOption), - _buildSortMenuItem(LibrarySortOption.progress, 'Progress', provider.sortOption), - ], - ); - } - - PopupMenuItem _buildSortMenuItem( - LibrarySortOption option, - String label, - LibrarySortOption current, - ) { - return PopupMenuItem( - value: option, - child: Row( - children: [ - Expanded(child: Text(label)), - Icon( - Icons.check, - size: IconSizes.small, - color: option == current ? Theme.of(context).colorScheme.primary : Colors.transparent, - ), - ], - ), - ); - } - Widget _buildDesktopLayout( BuildContext context, List books, - DataStore dataStore, + List sourceBooks, + LibraryFilterOptions filterOptions, LibraryProvider libraryProvider, AcquisitionDownloadsProvider? downloadsProvider, bool isLoading, ) { const double controlHeight = 40.0; - final isOnline = _presentationMode == _BooksPresentationMode.online && downloadsProvider != null; + final isOnline = + !widget.isShelfView && _presentationMode == _BooksPresentationMode.online && downloadsProvider != null; final isBookSelection = libraryProvider.isSelectionMode; - final localItems = buildAcquisitionLibraryItems(books: dataStore.books, jobs: downloadsProvider?.jobs ?? const []); + final localItems = buildAcquisitionLibraryItems( + books: sourceBooks, + jobs: widget.isShelfView ? const [] : downloadsProvider?.jobs ?? const [], + ); final showDownloadingOnly = _showDownloadingOnly && localItems.hasDownloadingItems; final acquisitionView = _buildAcquisitionLibraryView( books: books, @@ -795,7 +635,6 @@ class _LibraryPageState extends State { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row Container( padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), child: isOnline @@ -815,64 +654,37 @@ class _LibraryPageState extends State { onDeselectAll: libraryProvider.deselectAll, actions: buildBulkActionBar(context, libraryProvider), ) - : LayoutBuilder( - builder: (context, constraints) { - final useCompactLayout = constraints.maxWidth < 800; - - if (useCompactLayout) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded(child: _buildSearchBar(libraryProvider)), - const SizedBox(width: Spacing.sm), - _buildSortButton(libraryProvider), - ], - ), - const SizedBox(height: Spacing.md), - Row( - children: [ - const Spacer(), - _buildViewToggle(libraryProvider), - const SizedBox(width: Spacing.sm), - _buildAddBookButton(controlHeight, downloadsProvider), - ], - ), - ], - ); - } - - return Row( - children: [ - Expanded(child: _buildSearchBar(libraryProvider)), - const SizedBox(width: Spacing.md), - _buildSortButton(libraryProvider), - const SizedBox(width: Spacing.md), - _buildViewToggle(libraryProvider), - const SizedBox(width: Spacing.md), - _buildAddBookButton(controlHeight, downloadsProvider), - ], - ); - }, - ), + : _buildDesktopLocalHeader(libraryProvider, downloadsProvider, controlHeight), ), - // Filter chips if (!isOnline) - LibraryFilterChips( - horizontalPadding: Spacing.lg, - showDownloading: localItems.hasDownloadingItems, - isDownloadingSelected: showDownloadingOnly, - onDownloadingTapped: () => setState(() => _showDownloadingOnly = !_showDownloadingOnly), - onLibraryFilterTapped: () { - if (_showDownloadingOnly) { - setState(() => _showDownloadingOnly = false); - } - }, + Column( + children: [ + SizedBox(height: Spacing.sm), + LibraryFilterChips( + horizontalPadding: Spacing.lg, + filterOptions: filterOptions, + showDownloading: !widget.isShelfView && localItems.hasDownloadingItems, + isDownloadingSelected: showDownloadingOnly, + onDownloadingTapped: () => setState(() => _showDownloadingOnly = !_showDownloadingOnly), + onLibraryFilterTapped: () { + if (_showDownloadingOnly) { + setState(() => _showDownloadingOnly = false); + } + }, + ), + ], ), + // Book grid or list Expanded( - child: _buildBookContent(context, libraryProvider, downloadsProvider, isLoading, acquisitionView), + child: _buildBookContent( + context, + libraryProvider, + downloadsProvider, + isLoading, + acquisitionView, + sourceBooks.length, + ), ), ], ), @@ -881,14 +693,118 @@ class _LibraryPageState extends State { ); } + Widget _buildDesktopLocalHeader( + LibraryProvider libraryProvider, + AcquisitionDownloadsProvider? downloadsProvider, + double controlHeight, + ) { + final searchAndAction = Row( + children: [ + Expanded(child: _buildSearchBar(libraryProvider)), + const SizedBox(width: Spacing.md), + FilledButton.icon( + onPressed: widget.isShelfView ? () {} : () => _showAddBook(downloadsProvider), + icon: const Icon(Icons.add), + label: Text(widget.isShelfView ? 'Add to shelf' : 'Add book'), + style: FilledButton.styleFrom(minimumSize: Size(0, controlHeight)), + ), + ], + ); + + if (!widget.isShelfView) { + return searchAndAction; + } + + return Column( + children: [ + _buildShelfIdentity(context, showBack: true, compact: false), + const SizedBox(height: Spacing.md), + searchAndAction, + ], + ); + } + + 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'), + const SizedBox(width: Spacing.sm), + ], + 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 + Semantics( + label: 'Edit shelf', + button: true, + enabled: true, + onTap: widget.onEditShelf, + excludeSemantics: true, + child: Tooltip( + message: 'Edit shelf', + child: 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), + ), + ], + ), + ), + ), + ], + ); + } + Widget _buildBookContent( BuildContext context, LibraryProvider libraryProvider, AcquisitionDownloadsProvider? downloadsProvider, bool isLoading, _AcquisitionLibraryView acquisitionView, + int sourceBookCount, ) { - if (_presentationMode == _BooksPresentationMode.online && downloadsProvider != null) { + if (!widget.isShelfView && _presentationMode == _BooksPresentationMode.online && downloadsProvider != null) { return OnlineResultsView( hasSearched: downloadsProvider.remoteQuery != null, isSearching: downloadsProvider.isSearching, @@ -910,7 +826,7 @@ class _LibraryPageState extends State { final visiblePlaceholderJobs = acquisitionView.placeholderJobs; if (visibleBooks.isEmpty && visiblePlaceholderJobs.isEmpty) { - return _buildEmptyState(libraryProvider, downloadsProvider); + return _buildEmptyState(libraryProvider, downloadsProvider, sourceBookCount); } void showJob(AcquisitionJob job) { @@ -923,7 +839,7 @@ class _LibraryPageState extends State { downloadsProvider?.toggleJobSelection(job.id); } - if (libraryProvider.isListView) { + if (libraryProvider.viewMode == LibraryViewMode.list) { return _buildBookList( context, visibleBooks, @@ -937,6 +853,7 @@ class _LibraryPageState extends State { return BookGrid( books: visibleBooks, + libraryViewMode: libraryProvider.viewMode, acquisitionJobsByBookId: _linkedJobsByBookId(acquisitionView.selectableJobs), placeholderJobs: visiblePlaceholderJobs, selectedAcquisitionJobIds: downloadsProvider?.selectedJobIds ?? const {}, @@ -1089,9 +1006,30 @@ class _LibraryPageState extends State { context.go('/library/details/${book.id}'); } - Widget _buildEmptyState(LibraryProvider libraryProvider, AcquisitionDownloadsProvider? downloadsProvider) { + Widget _buildEmptyState( + LibraryProvider libraryProvider, + AcquisitionDownloadsProvider? downloadsProvider, + int sourceBookCount, + ) { final query = libraryProvider.searchQuery.trim(); + if (widget.isShelfView) { + if (sourceBookCount == 0) { + return EmptyState( + icon: Icons.menu_book_outlined, + title: 'No books in this shelf', + subtitle: 'Add books from your library to organize them here', + action: FilledButton(onPressed: () {}, child: const Text('Add to shelf')), + ); + } + + return const EmptyState( + icon: Icons.search_off, + title: 'No books found', + subtitle: 'Try adjusting your search or filters', + ); + } + if (query.isNotEmpty) { return EmptyState( icon: Icons.search_off, diff --git a/app/lib/pages/shelf_contents_page.dart b/app/lib/pages/shelf_contents_page.dart index 8b7f354..1829dc4 100644 --- a/app/lib/pages/shelf_contents_page.dart +++ b/app/lib/pages/shelf_contents_page.dart @@ -1,89 +1,41 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/pages/library_page.dart'; import 'package:papyrus/providers/library_provider.dart'; -import 'package:papyrus/providers/shelves_provider.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/utils/bulk_book_actions.dart'; -import 'package:papyrus/utils/search_query_parser.dart'; -import 'package:papyrus/widgets/filter/filter_bottom_sheet.dart'; -import 'package:papyrus/widgets/filter/filter_dialog.dart'; -import 'package:papyrus/widgets/library/book_card.dart'; -import 'package:papyrus/widgets/library/book_list_item.dart'; -import 'package:papyrus/widgets/library/library_drawer.dart'; -import 'package:papyrus/widgets/library/selection_header.dart'; -import 'package:papyrus/widgets/search/library_search_bar.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; -import 'package:papyrus/widgets/shared/quick_filter_chips.dart'; -import 'package:papyrus/widgets/shared/view_mode_toggle.dart'; -import 'package:papyrus/widgets/shelves/shelf_card.dart'; +import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; import 'package:provider/provider.dart'; -/// Page for viewing books and child shelves within a specific shelf. -class ShelfContentsPage extends StatefulWidget { +/// Route adapter for viewing books within a specific shelf. +class ShelfContentsPage extends StatelessWidget { final String? shelfId; const ShelfContentsPage({super.key, required this.shelfId}); - @override - State createState() => _ShelfContentsPageState(); -} - -class _ShelfContentsPageState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - late ShelvesProvider _provider; - - @override - void initState() { - super.initState(); - _provider = ShelvesProvider(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final dataStore = context.read(); - _provider.attach(dataStore); - } - - @override - void dispose() { - _provider.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final dataStore = context.watch(); - final shelf = dataStore.getShelf(widget.shelfId ?? ''); + final shelf = dataStore.getShelf(shelfId ?? ''); if (shelf == null) { return _buildNotFound(context); } - return ChangeNotifierProvider.value( - value: _provider, - child: Consumer( - builder: (context, provider, _) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; + final favoriteState = context.read(); - if (isDesktop) { - return _buildDesktopLayout(context, shelf, provider); - } - return _buildMobileLayout(context, shelf, provider); - }, + 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), ), ); } - // ============================================================================ - // NOT FOUND STATE - // ============================================================================ - Widget _buildNotFound(BuildContext context) { return Scaffold( body: SafeArea( @@ -103,471 +55,20 @@ class _ShelfContentsPageState extends State { ); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - - Widget _buildMobileLayout(BuildContext context, Shelf shelf, ShelvesProvider provider) { - final libraryProvider = context.watch(); - final childShelves = provider.getChildShelves(shelf.id); - final books = provider.getFilteredBooksForShelf( - shelf.id, - isFavorite: (bookId) { - final book = context.read().getBook(bookId); - return libraryProvider.isBookFavorite(bookId, book?.isFavorite ?? false); - }, - ); - - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final shelfColor = shelf.color ?? colorScheme.primary; - final isSelectionMode = libraryProvider.isSelectionMode; - - return Scaffold( - key: _scaffoldKey, - drawer: const LibraryDrawer(currentPath: '/library/shelves'), - body: SafeArea( - child: Column( - children: [ - // Row 1: Selection header or Back + Search + Sort - Padding( - padding: const EdgeInsets.only(top: Spacing.md, left: Spacing.md, right: Spacing.md), - child: isSelectionMode - ? SelectionHeader( - selectedCount: libraryProvider.selectedCount, - totalCount: books.length, - onClose: libraryProvider.exitSelectionMode, - onSelectAll: () => libraryProvider.selectAll(books.map((b) => b.id).toList()), - onDeselectAll: libraryProvider.deselectAll, - ) - : Row( - children: [ - IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/library/shelves')), - const SizedBox(width: Spacing.xs), - Expanded(child: _buildSearchBar(provider, isDesktop: false)), - const SizedBox(width: Spacing.sm), - _buildSortButton(provider), - ], - ), - ), - // Filter chips - _buildFilterChips(provider), - // Row 2: Shelf info + View toggle (hidden during selection) - if (!isSelectionMode) - Padding( - padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - Icon(shelf.displayIcon, size: IconSizes.small, color: shelfColor), - const SizedBox(width: Spacing.xs), - Flexible( - child: Text( - shelf.name, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ), - const SizedBox(width: Spacing.sm), - Text( - '\u00b7 ${books.length} ${books.length == 1 ? 'book' : 'books'}', - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - const SizedBox(width: Spacing.sm), - _buildViewToggle(provider), - ], - ), - ), - // Content grid/list - Expanded(child: _buildContent(context, childShelves, books, provider, libraryProvider)), - ], - ), - ), - bottomNavigationBar: isSelectionMode ? buildMobileBottomActionBar(context, libraryProvider) : null, - ); - } - - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - - Widget _buildDesktopLayout(BuildContext context, Shelf shelf, ShelvesProvider provider) { - final libraryProvider = context.watch(); - final childShelves = provider.getChildShelves(shelf.id); - final books = provider.getFilteredBooksForShelf( - shelf.id, - isFavorite: (bookId) { - final book = context.read().getBook(bookId); - return libraryProvider.isBookFavorite(bookId, book?.isFavorite ?? false); - }, - ); - - final isSelectionMode = libraryProvider.isSelectionMode; - - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.escape): () { - if (libraryProvider.isSelectionMode) { - libraryProvider.exitSelectionMode(); - } - }, - }, - child: Focus( - autofocus: true, - child: Scaffold( - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), - child: isSelectionMode - ? SelectionHeader( - selectedCount: libraryProvider.selectedCount, - totalCount: books.length, - onClose: libraryProvider.exitSelectionMode, - onSelectAll: () => libraryProvider.selectAll(books.map((b) => b.id).toList()), - onDeselectAll: libraryProvider.deselectAll, - actions: buildBulkActionBar(context, libraryProvider), - ) - : LayoutBuilder( - builder: (context, constraints) { - final useCompactLayout = constraints.maxWidth < 800; - - if (useCompactLayout) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded(child: _buildSearchBar(provider, isDesktop: true)), - const SizedBox(width: Spacing.sm), - _buildSortButton(provider), - ], - ), - const SizedBox(height: Spacing.md), - Row(children: [const Spacer(), _buildViewToggle(provider)]), - ], - ); - } - - return Row( - children: [ - Expanded(child: _buildSearchBar(provider, isDesktop: true)), - const SizedBox(width: Spacing.md), - _buildSortButton(provider), - const SizedBox(width: Spacing.md), - _buildViewToggle(provider), - ], - ); - }, - ), - ), - // Filter chips - _buildFilterChips(provider, horizontalPadding: Spacing.lg), - // Content grid/list - Expanded(child: _buildContent(context, childShelves, books, provider, libraryProvider)), - ], - ), - ), - ), - ); - } - - // ============================================================================ - // HEADER CONTROLS - // ============================================================================ - - Widget _buildSearchBar(ShelvesProvider provider, {required bool isDesktop}) { - final activeFilterCount = _countActiveAdvancedFilters(provider); - - return LibrarySearchBar( - onQueryChanged: provider.setBookSearchQuery, - initialQuery: provider.bookSearchQuery, - activeFilterCount: activeFilterCount, - onFilterTap: () => isDesktop ? _showFilterDialog(context, provider) : _showFilterBottomSheet(context, provider), - ); - } - - Widget _buildSortButton(ShelvesProvider provider) { - return PopupMenuButton( - icon: const Icon(Icons.sort), - tooltip: 'Sort books', - onSelected: (option) => provider.setBookSortOption(option), - itemBuilder: (context) => [ - _buildSortMenuItem(BookSortOption.title, 'Title', provider), - _buildSortMenuItem(BookSortOption.author, 'Author', provider), - _buildSortMenuItem(BookSortOption.progress, 'Progress', provider), - _buildSortMenuItem(BookSortOption.dateAdded, 'Date added', provider), - ], - ); - } - - PopupMenuItem _buildSortMenuItem(BookSortOption option, String label, ShelvesProvider provider) { - return PopupMenuItem( - value: option, - child: Row( - children: [ - Expanded(child: Text(label)), - Icon( - Icons.check, - size: IconSizes.small, - color: option == provider.bookSortOption ? Theme.of(context).colorScheme.primary : Colors.transparent, - ), - ], - ), - ); - } - - Widget _buildViewToggle(ShelvesProvider provider) { - return ViewModeToggle( - isGridView: provider.isGridView, - onChanged: (isGrid) => provider.setViewMode(isGrid ? ShelvesViewMode.grid : ShelvesViewMode.list), - ); - } - - // ============================================================================ - // FILTER CHIPS - // ============================================================================ - - static const _quickFilters = [ - (type: BookFilterType.all, label: 'All', icon: Icons.apps), - (type: BookFilterType.reading, label: 'Reading', icon: Icons.auto_stories), - (type: BookFilterType.favorites, label: 'Favorites', icon: Icons.favorite), - (type: BookFilterType.finished, label: 'Finished', icon: Icons.check_circle), - (type: BookFilterType.unread, label: 'Unread', icon: Icons.book), - ]; - - Widget _buildFilterChips(ShelvesProvider provider, {double? horizontalPadding}) { - return QuickFilterChips( - horizontalPadding: horizontalPadding, - filters: _quickFilters - .map( - (f) => QuickFilterChipData(label: f.label, icon: f.icon, isSelected: provider.isBookFilterActive(f.type)), - ) - .toList(), - onFilterTapped: (index) => provider.toggleBookFilter(_quickFilters[index].type), - ); - } - - // ============================================================================ - // ADVANCED FILTERS - // ============================================================================ - - int _countActiveAdvancedFilters(ShelvesProvider provider) { - if (provider.bookSearchQuery.isEmpty || !provider.bookSearchQuery.contains(':')) { - return 0; - } - final query = SearchQueryParser.parse(provider.bookSearchQuery); - return query.filters.where((f) => f.field.name != 'any').length; - } - - Future _showFilterBottomSheet(BuildContext context, ShelvesProvider provider) async { - final dataStore = context.read(); - final filterOptions = FilterOptions.fromBooks( - dataStore.books, - shelfNames: dataStore.shelves.map((s) => s.name).toList(), - topicNames: dataStore.tags.map((t) => t.name).toList(), - ); - - final result = await FilterBottomSheet.show( - context, - filterOptions: filterOptions, - initialFilters: AppliedFilters.fromQueryString( - provider.bookSearchQuery, - filterReading: provider.isBookFilterActive(BookFilterType.reading), - filterFavorites: provider.isBookFilterActive(BookFilterType.favorites), - filterFinished: provider.isBookFilterActive(BookFilterType.finished), - filterUnread: provider.isBookFilterActive(BookFilterType.unread), - ), - ); - - if (result != null) { - _applyShelfFilterResult(result, provider); - } - } - - Future _showFilterDialog(BuildContext context, ShelvesProvider provider) async { - final dataStore = context.read(); - final filterOptions = FilterOptions.fromBooks( - dataStore.books, - shelfNames: dataStore.shelves.map((s) => s.name).toList(), - topicNames: dataStore.tags.map((t) => t.name).toList(), - ); - - final result = await FilterDialog.show( + void _editShelf(BuildContext context, DataStore dataStore, Shelf shelf) { + AddShelfSheet.show( context, - filterOptions: filterOptions, - initialFilters: AppliedFilters.fromQueryString( - provider.bookSearchQuery, - filterReading: provider.isBookFilterActive(BookFilterType.reading), - filterFavorites: provider.isBookFilterActive(BookFilterType.favorites), - filterFinished: provider.isBookFilterActive(BookFilterType.finished), - filterUnread: provider.isBookFilterActive(BookFilterType.unread), - ), - ); - - if (result != null) { - _applyShelfFilterResult(result, provider); - } - } - - void _applyShelfFilterResult(AppliedFilters result, ShelvesProvider provider) { - // Apply quick filters - if (result.filterReading) { - provider.addBookFilter(BookFilterType.reading); - } else { - provider.removeBookFilter(BookFilterType.reading); - } - if (result.filterFavorites) { - provider.addBookFilter(BookFilterType.favorites); - } else { - provider.removeBookFilter(BookFilterType.favorites); - } - if (result.filterFinished) { - provider.addBookFilter(BookFilterType.finished); - } else { - provider.removeBookFilter(BookFilterType.finished); - } - if (result.filterUnread) { - provider.addBookFilter(BookFilterType.unread); - } else { - provider.removeBookFilter(BookFilterType.unread); - } - - // Set or clear search query from advanced filters - final queryString = result.toQueryString(); - if (queryString.isNotEmpty) { - provider.setBookSearchQuery(queryString); - } else { - provider.clearBookSearch(); - } - } - - // ============================================================================ - // CONTENT (MIXED GRID / LIST) - // ============================================================================ - - Widget _buildContent( - BuildContext context, - List childShelves, - List books, - ShelvesProvider provider, - LibraryProvider libraryProvider, - ) { - final totalItems = childShelves.length + books.length; - - if (totalItems == 0) { - return EmptyState( - icon: Icons.menu_book_outlined, - title: 'No books in this shelf', - subtitle: 'Add books from your library to organize them here', - ); - } - - if (provider.isListView) { - return _buildListContent(context, childShelves, books, provider, libraryProvider); - } - - return _buildGridContent(context, childShelves, books, provider, libraryProvider); - } - - Widget _buildGridContent( - BuildContext context, - List childShelves, - List books, - ShelvesProvider provider, - LibraryProvider libraryProvider, - ) { - final screenWidth = MediaQuery.of(context).size.width; - final totalItems = childShelves.length + books.length; - - int crossAxisCount; - double spacing; - double childAspectRatio; - - if (screenWidth >= Breakpoints.desktopLarge) { - crossAxisCount = 6; - spacing = Spacing.md; - childAspectRatio = 0.55; - } else if (screenWidth >= Breakpoints.desktopSmall) { - crossAxisCount = 5; - spacing = Spacing.md; - childAspectRatio = 0.55; - } else if (screenWidth >= Breakpoints.tablet) { - crossAxisCount = 4; - spacing = Spacing.sm + 4; - childAspectRatio = 0.55; - } else { - crossAxisCount = 2; - spacing = Spacing.sm; - childAspectRatio = 0.58; - } - - return GridView.builder( - padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - childAspectRatio: childAspectRatio, - ), - itemCount: totalItems, - itemBuilder: (context, index) { - if (index < childShelves.length) { - final shelf = childShelves[index]; - return ShelfCard(shelf: shelf, onTap: () => context.go('/library/shelves/${shelf.id}')); - } - - final book = books[index - childShelves.length]; - final isFavorite = libraryProvider.isBookFavorite(book.id, book.isFavorite); - final isSelectionMode = libraryProvider.isSelectionMode; - return BookCard( - book: book, - isFavorite: isFavorite, - isSelectionMode: isSelectionMode, - isSelected: libraryProvider.isBookSelected(book.id), - onSelectToggle: () => libraryProvider.toggleBookSelection(book.id), - onEnterSelectionMode: () => libraryProvider.enterSelectionMode(book.id), - onToggleFavorite: (current) => libraryProvider.toggleFavorite(book.id, current), - onTap: () => context.go('/library/details/${book.id}'), - ); - }, - ); - } - - Widget _buildListContent( - BuildContext context, - List childShelves, - List books, - ShelvesProvider provider, - LibraryProvider libraryProvider, - ) { - final totalItems = childShelves.length + books.length; - - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - itemCount: totalItems, - itemBuilder: (context, index) { - if (index < childShelves.length) { - final shelf = childShelves[index]; - return ShelfCard(shelf: shelf, isListItem: true, onTap: () => context.go('/library/shelves/${shelf.id}')); - } - - final book = books[index - childShelves.length]; - final isFavorite = libraryProvider.isBookFavorite(book.id, book.isFavorite); - return BookListItem( - book: book, - isFavorite: isFavorite, - isSelectionMode: libraryProvider.isSelectionMode, - isSelected: libraryProvider.isBookSelected(book.id), - onSelectToggle: () => libraryProvider.toggleBookSelection(book.id), - onTap: () => context.go('/library/details/${book.id}'), + 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(), + ), ); }, ); diff --git a/app/lib/pages/shelves_page.dart b/app/lib/pages/shelves_page.dart index 79f104b..083fa14 100644 --- a/app/lib/pages/shelves_page.dart +++ b/app/lib/pages/shelves_page.dart @@ -5,11 +5,11 @@ import 'package:papyrus/providers/shelves_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; -import 'package:papyrus/widgets/shared/view_mode_toggle.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; import 'package:papyrus/widgets/shelves/shelf_card.dart'; +import 'package:papyrus/widgets/shelves/shelves_filter_chips.dart'; import 'package:provider/provider.dart'; /// Shelves page for managing book collections. @@ -86,13 +86,14 @@ class _ShelvesPageState extends State { // ============================================================================ Widget _buildMobileLayout(BuildContext context, ShelvesProvider provider) { + final shelves = provider.shelves; + return Scaffold( key: _scaffoldKey, drawer: const LibraryDrawer(currentPath: '/library/shelves'), body: SafeArea( child: Column( children: [ - // Row 1: Menu + Search + Sort Padding( padding: const EdgeInsets.only(top: Spacing.md, left: Spacing.md, right: Spacing.md), child: Row( @@ -106,41 +107,18 @@ class _ShelvesPageState extends State { ), const SizedBox(width: Spacing.xs), Expanded(child: _buildSearchField(provider)), - const SizedBox(width: Spacing.sm), - _buildSortButton(provider), - ], - ), - ), - SizedBox(height: Spacing.md), - // Row 2: Count + View toggle - Padding( - padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${provider.shelves.length} ${provider.shelves.length == 1 ? 'shelf' : 'shelves'}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - _buildViewToggle(provider), ], ), ), - // Shelves grid or list - Expanded( - child: provider.hasShelves - ? provider.isListView - ? _buildShelfList(context, provider) - : _buildShelfGrid(context, provider) - : _buildEmptyState(context), - ), + const SizedBox(height: Spacing.sm), + const ShelvesFilterChips(), + Expanded(child: _buildShelfResults(context, provider, shelves)), ], ), ), floatingActionButton: FloatingActionButton( onPressed: () => _showAddShelfSheet(context), + tooltip: 'New shelf', child: const Icon(Icons.add), ), ); @@ -152,66 +130,45 @@ class _ShelvesPageState extends State { Widget _buildDesktopLayout(BuildContext context, ShelvesProvider provider) { const double controlHeight = 40.0; + final shelves = provider.shelves; return Scaffold( - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row - Container( - padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), - child: LayoutBuilder( - builder: (context, constraints) { - final useCompactLayout = constraints.maxWidth < 800; - - if (useCompactLayout) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), + child: LayoutBuilder( + builder: (context, constraints) { + final useCompactLayout = constraints.maxWidth < 800; + + if (useCompactLayout) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSearchField(provider), + const SizedBox(height: Spacing.sm), + Align(alignment: Alignment.centerRight, child: _buildNewShelfButton(controlHeight)), + ], + ); + } + + return Row( children: [ - Row( - children: [ - Expanded(child: _buildSearchField(provider)), - const SizedBox(width: Spacing.sm), - _buildSortButton(provider), - ], - ), - const SizedBox(height: Spacing.md), - Row( - children: [ - const Spacer(), - _buildViewToggle(provider), - const SizedBox(width: Spacing.sm), - _buildNewShelfButton(controlHeight), - ], - ), + Expanded(child: _buildSearchField(provider)), + const SizedBox(width: Spacing.md), + _buildNewShelfButton(controlHeight), ], ); - } - - return Row( - children: [ - Expanded(child: _buildSearchField(provider)), - const SizedBox(width: Spacing.md), - _buildSortButton(provider), - const SizedBox(width: Spacing.md), - _buildViewToggle(provider), - const SizedBox(width: Spacing.md), - _buildNewShelfButton(controlHeight), - ], - ); - }, + }, + ), ), - ), - const SizedBox(height: Spacing.md), - // Shelves grid or list - Expanded( - child: provider.hasShelves - ? provider.isListView - ? _buildShelfList(context, provider) - : _buildShelfGrid(context, provider) - : _buildEmptyState(context), - ), - ], + const SizedBox(height: Spacing.sm), + const ShelvesFilterChips(horizontalPadding: Spacing.lg), + Expanded(child: _buildShelfResults(context, provider, shelves)), + ], + ), ), ); } @@ -229,6 +186,7 @@ class _ShelvesPageState extends State { suffixIcon: provider.searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), + tooltip: 'Clear shelf search', onPressed: () { _searchController.clear(); provider.clearSearch(); @@ -243,43 +201,6 @@ class _ShelvesPageState extends State { ); } - Widget _buildSortButton(ShelvesProvider provider) { - return PopupMenuButton( - icon: const Icon(Icons.sort), - tooltip: 'Sort shelves', - onSelected: (option) => provider.setShelfSortOption(option), - itemBuilder: (context) => [ - _buildSortMenuItem(ShelfSortOption.name, 'Name', provider), - _buildSortMenuItem(ShelfSortOption.bookCount, 'Book count', provider), - _buildSortMenuItem(ShelfSortOption.dateCreated, 'Date created', provider), - _buildSortMenuItem(ShelfSortOption.dateModified, 'Date modified', provider), - ], - ); - } - - PopupMenuItem _buildSortMenuItem(ShelfSortOption option, String label, ShelvesProvider provider) { - return PopupMenuItem( - value: option, - child: Row( - children: [ - Expanded(child: Text(label)), - Icon( - Icons.check, - size: IconSizes.small, - color: option == provider.shelfSortOption ? Theme.of(context).colorScheme.primary : Colors.transparent, - ), - ], - ), - ); - } - - Widget _buildViewToggle(ShelvesProvider provider) { - return ViewModeToggle( - isGridView: provider.isGridView, - onChanged: (isGrid) => provider.setViewMode(isGrid ? ShelvesViewMode.grid : ShelvesViewMode.list), - ); - } - Widget _buildNewShelfButton(double height) { return FilledButton.icon( onPressed: () => _showAddShelfSheet(context), @@ -293,59 +214,78 @@ class _ShelvesPageState extends State { // SHARED WIDGETS // ============================================================================ - Widget _buildShelfGrid(BuildContext context, ShelvesProvider provider) { - final screenWidth = MediaQuery.of(context).size.width; - - // Match book-card proportions for visual consistency - int crossAxisCount; - double spacing; - double childAspectRatio; - - if (screenWidth >= Breakpoints.desktopLarge) { - crossAxisCount = 6; - spacing = Spacing.md; - childAspectRatio = 0.55; - } else if (screenWidth >= Breakpoints.desktopSmall) { - crossAxisCount = 5; - spacing = Spacing.md; - childAspectRatio = 0.55; - } else if (screenWidth >= Breakpoints.tablet) { - crossAxisCount = 4; - spacing = Spacing.sm + 4; - childAspectRatio = 0.55; - } else { - crossAxisCount = 2; - spacing = Spacing.sm; - childAspectRatio = 0.58; + Widget _buildShelfResults(BuildContext context, ShelvesProvider provider, List shelves) { + 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); + } - return GridView.builder( - padding: const EdgeInsets.fromLTRB(Spacing.md, 0, Spacing.md, Spacing.md), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - childAspectRatio: childAspectRatio, - ), - itemCount: provider.shelves.length, - itemBuilder: (context, index) { - final shelf = provider.shelves[index]; - return ShelfCard( - shelf: shelf, - onTap: () => _showShelfDetail(context, shelf), - onMoreTap: () => _showShelfOptions(context, shelf), - onLongPress: () => _showShelfOptions(context, shelf), + Widget _buildShelfGrid(BuildContext context, List shelves, ShelvesViewMode viewMode) { + final isLargeGrid = viewMode == ShelvesViewMode.largeGrid; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + + // Match book-card proportions for visual consistency. + int crossAxisCount; + double spacing; + double childAspectRatio; + + if (width >= Breakpoints.desktopLarge) { + crossAxisCount = isLargeGrid ? 4 : 6; + spacing = Spacing.md; + childAspectRatio = 0.55; + } else if (width >= Breakpoints.desktopSmall) { + crossAxisCount = isLargeGrid ? 3 : 5; + spacing = Spacing.md; + childAspectRatio = 0.55; + } else if (width >= Breakpoints.tablet) { + crossAxisCount = isLargeGrid ? 3 : 4; + spacing = Spacing.sm + 4; + childAspectRatio = 0.55; + } else { + crossAxisCount = 2; + spacing = Spacing.sm; + childAspectRatio = 0.58; + } + + return GridView.builder( + padding: const EdgeInsets.fromLTRB(Spacing.md, 0, Spacing.md, Spacing.md), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + childAspectRatio: childAspectRatio, + ), + itemCount: shelves.length, + itemBuilder: (context, index) { + final shelf = shelves[index]; + return ShelfCard( + shelf: shelf, + onTap: () => _showShelfDetail(context, shelf), + onMoreTap: () => _showShelfOptions(context, shelf), + onLongPress: () => _showShelfOptions(context, shelf), + ); + }, ); }, ); } - Widget _buildShelfList(BuildContext context, ShelvesProvider provider) { + Widget _buildShelfList(BuildContext context, List shelves) { return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - itemCount: provider.shelves.length, + itemCount: shelves.length, itemBuilder: (context, index) { - final shelf = provider.shelves[index]; + final shelf = shelves[index]; return ShelfCard( shelf: shelf, isListItem: true, @@ -357,6 +297,14 @@ class _ShelvesPageState extends State { ); } + Widget _buildNoResultsState(BuildContext context) { + return const EmptyState( + icon: Icons.search_off, + title: 'No shelves found', + subtitle: 'Try changing your search or filters', + ); + } + Widget _buildEmptyState(BuildContext context) { return EmptyState( icon: Icons.shelves, @@ -388,7 +336,14 @@ class _ShelvesPageState extends State { context, shelf: shelf, onSave: (name, description, colorHex, icon) { - _provider.updateShelf(shelfId: shelf.id, name: name, description: description, colorHex: colorHex, icon: icon); + _provider.updateShelf( + shelfId: shelf.id, + name: name, + description: description, + clearDescription: description == null, + colorHex: colorHex, + icon: icon, + ); }, ); } @@ -402,6 +357,7 @@ class _ShelvesPageState extends State { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (context) => SafeArea( child: Padding( diff --git a/app/lib/powersync/powersync_book_mapper.dart b/app/lib/powersync/powersync_book_mapper.dart index 3483506..952066a 100644 --- a/app/lib/powersync/powersync_book_mapper.dart +++ b/app/lib/powersync/powersync_book_mapper.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; const syncedBookColumns = [ 'title', @@ -260,13 +261,13 @@ WHERE id = ? return false; } - static ReadingStatus _readingStatus(Object? value) { + static LibraryReadingStatus _readingStatus(Object? value) { return switch (value) { - 'inProgress' || 'in_progress' || 'reading' => ReadingStatus.inProgress, - 'completed' || 'finished' => ReadingStatus.completed, - 'paused' => ReadingStatus.paused, - 'abandoned' => ReadingStatus.abandoned, - _ => ReadingStatus.notStarted, + 'inProgress' || 'in_progress' || 'reading' => LibraryReadingStatus.inProgress, + 'completed' || 'finished' => LibraryReadingStatus.completed, + 'paused' => LibraryReadingStatus.paused, + 'abandoned' => LibraryReadingStatus.abandoned, + _ => LibraryReadingStatus.unread, }; } diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 08d69f2..0b5a38e 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -5,6 +5,7 @@ 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/providers/enums/library_reading_status.dart'; /// Tab indices for book details page. enum BookDetailsTab { details, bookmarks, annotations, notes } @@ -273,9 +274,9 @@ class BookDetailsProvider extends ChangeNotifier { currentPage: page, currentPosition: position.clamp(0.0, 1.0), readingStatus: position >= 1.0 - ? ReadingStatus.completed + ? LibraryReadingStatus.completed : position > 0 - ? ReadingStatus.inProgress + ? LibraryReadingStatus.inProgress : _book!.readingStatus, lastReadAt: DateTime.now(), ); diff --git a/app/lib/providers/dashboard_provider.dart b/app/lib/providers/dashboard_provider.dart index dc8dad5..cb6722a 100644 --- a/app/lib/providers/dashboard_provider.dart +++ b/app/lib/providers/dashboard_provider.dart @@ -3,6 +3,7 @@ import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/daily_activity.dart'; import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; /// Provider for dashboard state management. /// Uses DataStore as the single source of truth. @@ -54,8 +55,11 @@ class DashboardProvider extends ChangeNotifier { /// Get current book (most recently read with progress < 100%). Book? get currentBook { if (_dataStore == null) return null; - final readingBooks = _dataStore!.books.where((b) => b.isReading).toList() - ..sort((a, b) => (b.lastReadAt ?? DateTime(2000)).compareTo(a.lastReadAt ?? DateTime(2000))); + + final readingBooks = + _dataStore!.books.where((book) => book.readingStatus == LibraryReadingStatus.inProgress).toList() + ..sort((a, b) => (b.lastReadAt ?? DateTime(2000)).compareTo(a.lastReadAt ?? DateTime(2000))); + return readingBooks.isNotEmpty ? readingBooks.first : null; } diff --git a/app/lib/providers/enums/library_reading_status.dart b/app/lib/providers/enums/library_reading_status.dart new file mode 100644 index 0000000..e96bde8 --- /dev/null +++ b/app/lib/providers/enums/library_reading_status.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +enum LibraryReadingStatus { unread, inProgress, completed, paused, abandoned } + +extension LibraryReadingStatusExtension on LibraryReadingStatus { + String get label { + switch (this) { + case LibraryReadingStatus.unread: + return 'Not started'; + case LibraryReadingStatus.inProgress: + return 'Reading'; + case LibraryReadingStatus.completed: + return 'Completed'; + case LibraryReadingStatus.paused: + return 'Paused'; + case LibraryReadingStatus.abandoned: + return 'Abandoned'; + } + } + + IconData get icon { + switch (this) { + case LibraryReadingStatus.unread: + return Icons.book_outlined; + case LibraryReadingStatus.inProgress: + return Icons.auto_stories_outlined; + case LibraryReadingStatus.completed: + return Icons.check_circle_outline; + case LibraryReadingStatus.paused: + return Icons.pause_circle_outline; + case LibraryReadingStatus.abandoned: + return Icons.cancel_outlined; + } + } +} diff --git a/app/lib/providers/enums/library_sort_option.dart b/app/lib/providers/enums/library_sort_option.dart new file mode 100644 index 0000000..c8cf800 --- /dev/null +++ b/app/lib/providers/enums/library_sort_option.dart @@ -0,0 +1,42 @@ +enum LibrarySortOption { + dateAddedNewest, + dateAddedOldest, + titleAZ, + titleZA, + authorAZ, + authorZA, + lastRead, + progressAsc, + progressDesc, + ratingAsc, + ratingDesc, +} + +extension LibrarySortOptionExtension on LibrarySortOption { + String get label { + switch (this) { + case LibrarySortOption.dateAddedNewest: + return 'Date added (newest)'; + case LibrarySortOption.dateAddedOldest: + return 'Date added (oldest)'; + case LibrarySortOption.titleAZ: + return 'Title (A-Z)'; + case LibrarySortOption.titleZA: + return 'Title (Z-A)'; + case LibrarySortOption.authorAZ: + return 'Author (A-Z)'; + case LibrarySortOption.authorZA: + return 'Author (Z-A)'; + case LibrarySortOption.lastRead: + return 'Last read'; + case LibrarySortOption.progressAsc: + return 'Progress (Ascending)'; + case LibrarySortOption.progressDesc: + return 'Progress (Descending)'; + case LibrarySortOption.ratingAsc: + return 'Rating (Ascending)'; + case LibrarySortOption.ratingDesc: + return 'Rating (Descending)'; + } + } +} diff --git a/app/lib/providers/enums/library_view_mode.dart b/app/lib/providers/enums/library_view_mode.dart new file mode 100644 index 0000000..4ca4908 --- /dev/null +++ b/app/lib/providers/enums/library_view_mode.dart @@ -0,0 +1,14 @@ +enum LibraryViewMode { smallGrid, largeGrid, list } + +extension LibraryViewModeExtension on LibraryViewMode { + String get label { + switch (this) { + case LibraryViewMode.smallGrid: + return 'Small grid'; + case LibraryViewMode.largeGrid: + return 'Large grid'; + case LibraryViewMode.list: + return 'List'; + } + } +} diff --git a/app/lib/providers/library_provider.dart b/app/lib/providers/library_provider.dart index c9df5b5..ac557d0 100644 --- a/app/lib/providers/library_provider.dart +++ b/app/lib/providers/library_provider.dart @@ -1,33 +1,23 @@ import 'package:flutter/foundation.dart'; +import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/library_filters.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/enums/library_sort_option.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; +import 'package:papyrus/utils/book_language.dart'; -/// View mode for displaying books in the library. -enum LibraryViewMode { grid, list } - -/// Active filter type for library content. -enum LibraryFilterType { all, shelves, topics, favorites, reading, finished, unread } - -/// Sort options for library books. Each value encodes its direction. -enum LibrarySortOption { - dateAddedNewest, - dateAddedOldest, - titleAZ, - titleZA, - authorAZ, - authorZA, - lastRead, - rating, - progress, -} - -/// Provider for managing library view state. class LibraryProvider extends ChangeNotifier { - LibraryViewMode _viewMode = LibraryViewMode.grid; - final Set _activeFilters = {LibraryFilterType.all}; - LibrarySortOption _sortOption = LibrarySortOption.dateAddedNewest; + final LibraryProvider? _favoriteDelegate; + + LibraryProvider({LibraryProvider? favoriteDelegate}) : _favoriteDelegate = favoriteDelegate { + _favoriteDelegate?.addListener(_onFavoriteDelegateChanged); + } + String _searchQuery = ''; - String? _selectedShelf; - String? _selectedTopic; + LibraryFilters _filters = LibraryFilters(); + LibraryViewMode _viewMode = LibraryViewMode.smallGrid; + LibrarySortOption _sortOption = LibrarySortOption.dateAddedNewest; // Selection mode state bool _isSelectionMode = false; @@ -49,55 +39,206 @@ class LibraryProvider extends ChangeNotifier { /// Current view mode (grid or list). LibraryViewMode get viewMode => _viewMode; - /// Active filters for library content. - Set get activeFilters => Set.unmodifiable(_activeFilters); - /// Current sort option. LibrarySortOption get sortOption => _sortOption; /// Current search query. String get searchQuery => _searchQuery; - /// Currently selected shelf for filtering. - String? get selectedShelf => _selectedShelf; + LibraryFilters get filters => _filters; + + int get activeFilterCount => _filters.activeCategoryCount; - /// Currently selected topic for filtering. - String? get selectedTopic => _selectedTopic; + Set get selectedAuthors => _filters.authors; - /// Whether the search query contains advanced field filters. - bool get hasActiveAdvancedFilters => _searchQuery.contains(':'); + Set get selectedLanguages => _filters.languages; - /// Whether the list view is currently active. - bool get isListView => _viewMode == LibraryViewMode.list; + Set get selectedFormats => _filters.formats; - /// Whether the grid view is currently active. - bool get isGridView => _viewMode == LibraryViewMode.grid; + Set get selectedShelfIds => _filters.shelfIds; + + Set get selectedTopicIds => _filters.topicIds; + + Set get selectedStatuses => _filters.statuses; + + FavoriteFilter get favoriteFilter => _filters.favoriteFilter; - /// Set the view mode. void setViewMode(LibraryViewMode mode) { - if (_viewMode != mode) { - _viewMode = mode; - notifyListeners(); + if (_viewMode == mode) { + return; + } + + switch (mode) { + case LibraryViewMode.smallGrid: + _viewMode = LibraryViewMode.smallGrid; + break; + case LibraryViewMode.largeGrid: + _viewMode = LibraryViewMode.largeGrid; + break; + case LibraryViewMode.list: + _viewMode = LibraryViewMode.list; + break; } - } - /// Toggle between grid and list view. - void toggleViewMode() { - _viewMode = _viewMode == LibraryViewMode.grid ? LibraryViewMode.list : LibraryViewMode.grid; notifyListeners(); } - /// Set the sort option. void setSortOption(LibrarySortOption option) { - if (_sortOption != option) { - _sortOption = option; - notifyListeners(); + if (_sortOption == option) { + return; + } + + _sortOption = option; + notifyListeners(); + } + + void setStatusFilters(Set statuses) { + applyFilters(_filters.copyWith(statuses: statuses)); + } + + void setFavoriteFilter(FavoriteFilter favoriteFilter) { + applyFilters(_filters.copyWith(favoriteFilter: favoriteFilter)); + } + + void setAuthorFilters(Set authors) { + applyFilters(_filters.copyWith(authors: _normalizeFilterValues(authors))); + } + + void setLanguageFilters(Set languages) { + applyFilters(_filters.copyWith(languages: languages.map(normalizeBookLanguage).whereType().toSet())); + } + + void setFormatFilters(Set formats) { + applyFilters(_filters.copyWith(formats: _normalizeFilterValues(formats))); + } + + void setShelfFilters(Set shelfIds) { + applyFilters(_filters.copyWith(shelfIds: shelfIds)); + } + + void setTopicFilters(Set topicIds) { + applyFilters(_filters.copyWith(topicIds: topicIds)); + } + + void applyFilters(LibraryFilters filters) { + if (_filters == filters) { + return; } + + _filters = filters; + notifyListeners(); + } + + void clearFilters() { + applyFilters(LibraryFilters()); + } + + void resetQuickFilters() { + final hasChanges = + !_filters.isEmpty || _sortOption != LibrarySortOption.dateAddedNewest || _viewMode != LibraryViewMode.smallGrid; + + if (!hasChanges) { + return; + } + + _filters = LibraryFilters(); + _sortOption = LibrarySortOption.dateAddedNewest; + _viewMode = LibraryViewMode.smallGrid; + notifyListeners(); + } + + List filterBooks(List books, {required DataStore dataStore, LibraryFilters? filters}) { + final searchQuery = _searchQuery.trim().toLowerCase(); + final appliedFilters = filters ?? _filters; + + return books.where((book) { + if (searchQuery.isNotEmpty && + !book.title.toLowerCase().contains(searchQuery) && + !book.allAuthors.toLowerCase().contains(searchQuery)) { + return false; + } + + if (appliedFilters.statuses.isNotEmpty && !appliedFilters.statuses.contains(book.readingStatus)) { + return false; + } + + final isFavorite = isBookFavorite(book.id, book.isFavorite); + if (appliedFilters.favoriteFilter == FavoriteFilter.favorites && !isFavorite) { + return false; + } + + if (appliedFilters.favoriteFilter == FavoriteFilter.notFavorites && isFavorite) { + return false; + } + + if (appliedFilters.authors.isNotEmpty && + ![ + book.author, + ...book.coAuthors, + ].any((author) => appliedFilters.authors.contains(_normalizeFilterValue(author)))) { + return false; + } + + if (appliedFilters.languages.isNotEmpty && + !appliedFilters.languages.contains(normalizeBookLanguage(book.language))) { + return false; + } + + if (appliedFilters.formats.isNotEmpty && + !appliedFilters.formats.contains(_normalizeFilterValue(book.formatLabel))) { + return false; + } + + if (appliedFilters.shelfIds.isNotEmpty && + !dataStore.getShelfIdsForBook(book.id).any(appliedFilters.shelfIds.contains)) { + return false; + } + + if (appliedFilters.topicIds.isNotEmpty && + !dataStore.getTagIdsForBook(book.id).any(appliedFilters.topicIds.contains)) { + return false; + } + + if (appliedFilters.publishers.isNotEmpty && + !appliedFilters.publishers.contains(_normalizeFilterValue(book.publisher))) { + return false; + } + + if (appliedFilters.seriesNames.isNotEmpty && + !appliedFilters.seriesNames.contains(_normalizeFilterValue(book.seriesName))) { + return false; + } + + if (appliedFilters.progressRange case final range? when !range.contains(book.currentPosition)) { + return false; + } + + if (appliedFilters.ratings.isNotEmpty || appliedFilters.includeUnrated) { + final rating = book.rating; + if (rating == null ? !appliedFilters.includeUnrated : !appliedFilters.ratings.contains(rating)) { + return false; + } + } + + if (appliedFilters.publicationDateRange case final range? when !range.contains(book.publicationDate)) { + return false; + } + + if (appliedFilters.dateAddedRange case final range? when !range.contains(book.addedAt)) { + return false; + } + + if (appliedFilters.lastReadDateRange case final range? when !range.contains(book.lastReadAt)) { + return false; + } + + return true; + }).toList(); } - /// Sort a list of books according to the current sort option. List sortBooks(List books) { final sorted = List.of(books); + sorted.sort((a, b) { switch (_sortOption) { case LibrarySortOption.dateAddedNewest: @@ -113,70 +254,28 @@ class LibraryProvider extends ChangeNotifier { case LibrarySortOption.authorZA: return b.author.toLowerCase().compareTo(a.author.toLowerCase()); case LibrarySortOption.lastRead: - // Nulls sort to end if (a.lastReadAt == null && b.lastReadAt == null) return 0; if (a.lastReadAt == null) return 1; if (b.lastReadAt == null) return -1; return b.lastReadAt!.compareTo(a.lastReadAt!); - case LibrarySortOption.rating: - // Nulls sort to end, highest first + case LibrarySortOption.ratingAsc: + if (a.rating == null && b.rating == null) return 0; + if (a.rating == null) return 1; + if (b.rating == null) return -1; + return a.rating!.compareTo(b.rating!); + case LibrarySortOption.ratingDesc: if (a.rating == null && b.rating == null) return 0; if (a.rating == null) return 1; if (b.rating == null) return -1; return b.rating!.compareTo(a.rating!); - case LibrarySortOption.progress: + case LibrarySortOption.progressAsc: + return a.currentPosition.compareTo(b.currentPosition); + case LibrarySortOption.progressDesc: return b.currentPosition.compareTo(a.currentPosition); } }); - return sorted; - } - - /// Set a single filter (replaces existing filters). - void setFilter(LibraryFilterType filter) { - _activeFilters.clear(); - _activeFilters.add(filter); - // Clear shelf/topic selection when switching filters - if (filter != LibraryFilterType.shelves) { - _selectedShelf = null; - } - if (filter != LibraryFilterType.topics) { - _selectedTopic = null; - } - notifyListeners(); - } - - /// Add a filter to active filters. - void addFilter(LibraryFilterType filter) { - // Remove 'all' filter when adding specific filters - if (filter != LibraryFilterType.all) { - _activeFilters.remove(LibraryFilterType.all); - } - _activeFilters.add(filter); - notifyListeners(); - } - /// Remove a filter from active filters. - void removeFilter(LibraryFilterType filter) { - _activeFilters.remove(filter); - // If no filters remain, default to 'all' - if (_activeFilters.isEmpty) { - _activeFilters.add(LibraryFilterType.all); - } - notifyListeners(); - } - - /// Toggle a filter on/off. - void toggleFilter(LibraryFilterType filter) { - if (_activeFilters.contains(filter)) { - removeFilter(filter); - } else { - addFilter(filter); - } - } - - /// Check if a specific filter is active. - bool isFilterActive(LibraryFilterType filter) { - return _activeFilters.contains(filter); + return sorted; } /// Set the search query. @@ -187,60 +286,45 @@ class LibraryProvider extends ChangeNotifier { } } - /// Clear the search query and associated shelf/topic filters. + /// Clear only the text search query. void clearSearch() { - _searchQuery = ''; - _selectedShelf = null; - _selectedTopic = null; - _activeFilters.remove(LibraryFilterType.shelves); - _activeFilters.remove(LibraryFilterType.topics); - if (_activeFilters.isEmpty) { - _activeFilters.add(LibraryFilterType.all); - } - notifyListeners(); - } - - /// Select a shelf for filtering. - void selectShelf(String? shelf) { - _selectedShelf = shelf; - if (shelf != null) { - setFilter(LibraryFilterType.shelves); - } - notifyListeners(); - } - - /// Select a topic for filtering. - void selectTopic(String? topic) { - _selectedTopic = topic; - if (topic != null) { - setFilter(LibraryFilterType.topics); + if (_searchQuery.isEmpty) { + return; } - notifyListeners(); - } - /// Reset all filters to default state. - void resetFilters() { - _activeFilters.clear(); - _activeFilters.add(LibraryFilterType.all); _searchQuery = ''; - _selectedShelf = null; - _selectedTopic = null; notifyListeners(); } /// Check if a book is favorited (considering overrides). bool isBookFavorite(String bookId, bool originalFavorite) { + final favoriteDelegate = _favoriteDelegate; + if (favoriteDelegate != null) { + return favoriteDelegate.isBookFavorite(bookId, originalFavorite); + } + return _favoriteOverrides[bookId] ?? originalFavorite; } /// Toggle the favorite status of a book. void toggleFavorite(String bookId, bool currentFavorite) { + final favoriteDelegate = _favoriteDelegate; + if (favoriteDelegate != null) { + favoriteDelegate.toggleFavorite(bookId, currentFavorite); + return; + } + _favoriteOverrides[bookId] = !currentFavorite; notifyListeners(); } /// Get the effective favorite status for a book. bool? getFavoriteOverride(String bookId) { + final favoriteDelegate = _favoriteDelegate; + if (favoriteDelegate != null) { + return favoriteDelegate.getFavoriteOverride(bookId); + } + return _favoriteOverrides[bookId]; } @@ -288,4 +372,23 @@ class LibraryProvider extends ChangeNotifier { _selectedBookIds.clear(); notifyListeners(); } + + static String? _normalizeFilterValue(String? value) { + final normalized = value?.trim().toLowerCase(); + return normalized == null || normalized.isEmpty ? null : normalized; + } + + static Set _normalizeFilterValues(Set values) { + return values.map(_normalizeFilterValue).whereType().toSet(); + } + + void _onFavoriteDelegateChanged() { + notifyListeners(); + } + + @override + void dispose() { + _favoriteDelegate?.removeListener(_onFavoriteDelegateChanged); + super.dispose(); + } } diff --git a/app/lib/providers/shelves_provider.dart b/app/lib/providers/shelves_provider.dart index d618078..02f39b8 100644 --- a/app/lib/providers/shelves_provider.dart +++ b/app/lib/providers/shelves_provider.dart @@ -1,20 +1,18 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/shelf.dart'; -import 'package:papyrus/utils/search_query_parser.dart'; /// View mode for displaying shelves. -enum ShelvesViewMode { grid, list } +enum ShelvesViewMode { smallGrid, largeGrid, list } -/// Sort options for shelves. -enum ShelfSortOption { name, bookCount, dateCreated, dateModified } +/// Filter options for shelf occupancy. +enum ShelfContentsFilter { all, withBooks, empty } -/// Sort options for books within a shelf. -enum BookSortOption { title, author, progress, dateAdded } +/// Filter options for shelf type. +enum ShelfTypeFilter { all, regular, smart } -/// Filter types for books within a shelf. -enum BookFilterType { all, reading, favorites, finished, unread } +/// Sort options for shelves. +enum ShelfSortOption { name, bookCount, dateCreated, dateModified } /// Provider for shelves page state management. /// Uses DataStore as the single source of truth. @@ -25,8 +23,10 @@ class ShelvesProvider extends ChangeNotifier { bool _isLoading = false; String? _error; - // View mode - ShelvesViewMode _viewMode = ShelvesViewMode.grid; + // Shelf collection controls + ShelvesViewMode _viewMode = ShelvesViewMode.smallGrid; + ShelfContentsFilter _contentsFilter = ShelfContentsFilter.all; + ShelfTypeFilter _typeFilter = ShelfTypeFilter.all; // Selected shelf for detail view Shelf? _selectedShelf; @@ -38,14 +38,6 @@ class ShelvesProvider extends ChangeNotifier { // Search String _searchQuery = ''; - // Sorting state for books within shelves - BookSortOption _bookSortOption = BookSortOption.title; - bool _bookSortAscending = true; - - // Book filtering state (for shelf contents page) - String _bookSearchQuery = ''; - final Set _activeBookFilters = {BookFilterType.all}; - /// Attach to a DataStore instance. void attach(DataStore dataStore) { if (_dataStore != dataStore) { @@ -78,9 +70,19 @@ class ShelvesProvider extends ChangeNotifier { String? get error => _error; ShelvesViewMode get viewMode => _viewMode; - bool get isGridView => _viewMode == ShelvesViewMode.grid; + ShelfContentsFilter get contentsFilter => _contentsFilter; + ShelfTypeFilter get typeFilter => _typeFilter; + bool get isSmallGridView => _viewMode == ShelvesViewMode.smallGrid; + bool get isLargeGridView => _viewMode == ShelvesViewMode.largeGrid; bool get isListView => _viewMode == ShelvesViewMode.list; + bool get hasActiveShelfControls => + _contentsFilter != ShelfContentsFilter.all || + _typeFilter != ShelfTypeFilter.all || + _shelfSortOption != ShelfSortOption.name || + !_shelfSortAscending || + _viewMode != ShelvesViewMode.smallGrid; + /// Get all shelves, filtered and sorted according to current settings. List get shelves { if (_dataStore == null) return []; @@ -91,11 +93,31 @@ class ShelvesProvider extends ChangeNotifier { return shelf.name.toLowerCase().contains(query) || (shelf.description?.toLowerCase().contains(query) ?? false); }).toList(); } + + 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(); + } + _applySorting(list); return list; } bool get hasShelves => shelves.isNotEmpty; + bool get hasAnyShelves => _dataStore?.shelves.isNotEmpty ?? false; Shelf? get selectedShelf => _selectedShelf; @@ -104,15 +126,6 @@ class ShelvesProvider extends ChangeNotifier { ShelfSortOption get shelfSortOption => _shelfSortOption; bool get shelfSortAscending => _shelfSortAscending; - BookSortOption get bookSortOption => _bookSortOption; - bool get bookSortAscending => _bookSortAscending; - - String get bookSearchQuery => _bookSearchQuery; - Set get activeBookFilters => Set.unmodifiable(_activeBookFilters); - - /// Whether a specific book filter is active. - bool isBookFilterActive(BookFilterType filter) => _activeBookFilters.contains(filter); - /// Get total book count across all shelves. int get totalBookCount { if (_dataStore == null) return 0; @@ -166,20 +179,38 @@ class ShelvesProvider extends ChangeNotifier { } } - /// Toggles between grid and list view. - void toggleViewMode() { - _viewMode = _viewMode == ShelvesViewMode.grid ? ShelvesViewMode.list : ShelvesViewMode.grid; - notifyListeners(); + /// Sets the shelf occupancy filter. + void setContentsFilter(ShelfContentsFilter filter) { + if (_contentsFilter != filter) { + _contentsFilter = filter; + notifyListeners(); + } } - /// Sets the shelf sort option. If the same option is selected, toggles direction. - void setShelfSortOption(ShelfSortOption option, {bool? ascending}) { - if (_shelfSortOption == option && ascending == null) { - _shelfSortAscending = !_shelfSortAscending; - } else { - _shelfSortOption = option; - if (ascending != null) _shelfSortAscending = ascending; + /// Sets the shelf type filter. + void setTypeFilter(ShelfTypeFilter filter) { + if (_typeFilter != filter) { + _typeFilter = filter; + notifyListeners(); } + } + + /// Sets the shelf sort option and its explicit direction. + void setShelfSortOption(ShelfSortOption option, {required bool ascending}) { + if (_shelfSortOption == option && _shelfSortAscending == ascending) return; + + _shelfSortOption = option; + _shelfSortAscending = ascending; + notifyListeners(); + } + + /// Resets shelf collection controls without changing text search. + void clearShelfControls() { + _contentsFilter = ShelfContentsFilter.all; + _typeFilter = ShelfTypeFilter.all; + _shelfSortOption = ShelfSortOption.name; + _shelfSortAscending = true; + _viewMode = ShelvesViewMode.smallGrid; notifyListeners(); } @@ -203,139 +234,12 @@ class ShelvesProvider extends ChangeNotifier { }); } - /// Sets the book sort option for shelf detail view. - void setBookSortOption(BookSortOption option, {bool? ascending}) { - if (_bookSortOption == option && ascending == null) { - _bookSortAscending = !_bookSortAscending; - } else { - _bookSortOption = option; - if (ascending != null) _bookSortAscending = ascending; - } - notifyListeners(); - } - - /// Sorts a list of books according to current book sort settings. - List sortBooks(List books) { - final sorted = List.from(books); - sorted.sort((a, b) { - int result; - switch (_bookSortOption) { - case BookSortOption.title: - result = a.title.toLowerCase().compareTo(b.title.toLowerCase()); - case BookSortOption.author: - result = a.author.toLowerCase().compareTo(b.author.toLowerCase()); - case BookSortOption.progress: - result = a.currentPosition.compareTo(b.currentPosition); - case BookSortOption.dateAdded: - result = a.addedAt.compareTo(b.addedAt); - } - return _bookSortAscending ? result : -result; - }); - return sorted; - } - - /// Sets the book search query for shelf contents. - void setBookSearchQuery(String query) { - if (_bookSearchQuery != query) { - _bookSearchQuery = query; - notifyListeners(); - } - } - - /// Clears the book search query. - void clearBookSearch() { - if (_bookSearchQuery.isNotEmpty) { - _bookSearchQuery = ''; - notifyListeners(); - } - } - - /// Toggles a book filter type. - void toggleBookFilter(BookFilterType filter) { - if (filter == BookFilterType.all) { - _activeBookFilters.clear(); - _activeBookFilters.add(BookFilterType.all); - } else { - _activeBookFilters.remove(BookFilterType.all); - if (_activeBookFilters.contains(filter)) { - _activeBookFilters.remove(filter); - if (_activeBookFilters.isEmpty) { - _activeBookFilters.add(BookFilterType.all); - } - } else { - _activeBookFilters.add(filter); - } - } - notifyListeners(); - } - - /// Add a book filter to active filters. - void addBookFilter(BookFilterType filter) { - if (filter != BookFilterType.all) { - _activeBookFilters.remove(BookFilterType.all); - } - _activeBookFilters.add(filter); - notifyListeners(); - } - - /// Remove a book filter from active filters. - void removeBookFilter(BookFilterType filter) { - _activeBookFilters.remove(filter); - if (_activeBookFilters.isEmpty) { - _activeBookFilters.add(BookFilterType.all); - } - notifyListeners(); - } - - /// Resets all book filters to default. - void resetBookFilters() { - _activeBookFilters.clear(); - _activeBookFilters.add(BookFilterType.all); - _bookSearchQuery = ''; - notifyListeners(); - } - /// Gets child shelves of a parent shelf. List getChildShelves(String parentShelfId) { if (_dataStore == null) return []; return _dataStore!.getChildShelves(parentShelfId); } - /// Gets filtered and sorted books for a shelf, applying search and filters. - List getFilteredBooksForShelf(String shelfId, {bool Function(String bookId)? isFavorite}) { - if (_dataStore == null) return []; - - var books = _dataStore!.getBooksInShelf(shelfId); - - // Apply book search using SearchQueryParser - if (_bookSearchQuery.isNotEmpty) { - final searchQuery = SearchQueryParser.parse(_bookSearchQuery); - if (searchQuery.isNotEmpty) { - books = books.where((book) => searchQuery.matches(book, dataStore: _dataStore)).toList(); - } - } - - // Apply book filters (AND logic — each active filter narrows the list) - if (!_activeBookFilters.contains(BookFilterType.all)) { - if (_activeBookFilters.contains(BookFilterType.reading)) { - books = books.where((book) => book.isReading).toList(); - } - if (_activeBookFilters.contains(BookFilterType.favorites)) { - books = books.where((book) { - return isFavorite?.call(book.id) ?? book.isFavorite; - }).toList(); - } - if (_activeBookFilters.contains(BookFilterType.finished)) { - books = books.where((book) => book.isFinished).toList(); - } - if (_activeBookFilters.contains(BookFilterType.unread)) { - books = books.where((book) => book.readingStatus == ReadingStatus.notStarted).toList(); - } - } - - return sortBooks(books); - } - /// Selects a shelf for detail view. void selectShelf(Shelf? shelf) { _selectedShelf = shelf; @@ -369,6 +273,7 @@ class ShelvesProvider extends ChangeNotifier { required String shelfId, String? name, String? description, + bool clearDescription = false, String? colorHex, IconData? icon, }) async { @@ -384,6 +289,7 @@ class ShelvesProvider extends ChangeNotifier { final updatedShelf = shelf.copyWith( name: name, description: description, + clearDescription: clearDescription, colorHex: colorHex, icon: icon, updatedAt: DateTime.now(), @@ -465,14 +371,6 @@ class ShelvesProvider extends ChangeNotifier { } } - /// Gets books for a specific shelf, sorted according to current settings. - List getBooksForShelf(String shelfId) { - if (_dataStore == null) return []; - - final books = _dataStore!.getBooksInShelf(shelfId); - return sortBooks(books); - } - /// Get book count for a specific shelf. int getBookCountForShelf(String shelfId) { if (_dataStore == null) return 0; diff --git a/app/lib/reader/reader_book_adapter.dart b/app/lib/reader/reader_book_adapter.dart index d4c5188..307f3bb 100644 --- a/app/lib/reader/reader_book_adapter.dart +++ b/app/lib/reader/reader_book_adapter.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; @@ -50,9 +51,9 @@ final class ReaderBookAdapter { PdfReaderLocator() => null, }; final status = position >= 1 - ? ReadingStatus.completed + ? LibraryReadingStatus.completed : position > 0 - ? ReadingStatus.inProgress + ? LibraryReadingStatus.inProgress : book.readingStatus; return book.copyWith( diff --git a/app/lib/utils/book_language.dart b/app/lib/utils/book_language.dart new file mode 100644 index 0000000..81c9fad --- /dev/null +++ b/app/lib/utils/book_language.dart @@ -0,0 +1,122 @@ +const Map _languageLabels = { + 'ar': 'Arabic', + 'cs': 'Czech', + 'da': 'Danish', + 'de': 'German', + 'el': 'Greek', + 'en': 'English', + 'es': 'Spanish', + 'et': 'Estonian', + 'fi': 'Finnish', + 'fr': 'French', + 'he': 'Hebrew', + 'hi': 'Hindi', + 'hu': 'Hungarian', + 'is': 'Icelandic', + 'it': 'Italian', + 'ja': 'Japanese', + 'ko': 'Korean', + 'lt': 'Lithuanian', + 'lv': 'Latvian', + 'nl': 'Dutch', + 'no': 'Norwegian', + 'pl': 'Polish', + 'pt': 'Portuguese', + 'ro': 'Romanian', + 'ru': 'Russian', + 'sk': 'Slovak', + 'sv': 'Swedish', + 'tr': 'Turkish', + 'uk': 'Ukrainian', + 'zh': 'Chinese', +}; + +const Map _languageAliases = { + 'ara': 'ar', + 'arabic': 'ar', + 'ces': 'cs', + 'cze': 'cs', + 'czech': 'cs', + 'dan': 'da', + 'danish': 'da', + 'deu': 'de', + 'ger': 'de', + 'german': 'de', + 'ell': 'el', + 'gre': 'el', + 'greek': 'el', + 'eng': 'en', + 'english': 'en', + 'spa': 'es', + 'spanish': 'es', + 'est': 'et', + 'estonian': 'et', + 'fin': 'fi', + 'finnish': 'fi', + 'fra': 'fr', + 'fre': 'fr', + 'french': 'fr', + 'heb': 'he', + 'hebrew': 'he', + 'hin': 'hi', + 'hindi': 'hi', + 'hun': 'hu', + 'hungarian': 'hu', + 'isl': 'is', + 'ice': 'is', + 'icelandic': 'is', + 'ita': 'it', + 'italian': 'it', + 'jpn': 'ja', + 'japanese': 'ja', + 'kor': 'ko', + 'korean': 'ko', + 'lit': 'lt', + 'lithuanian': 'lt', + 'lav': 'lv', + 'latvian': 'lv', + 'nld': 'nl', + 'dut': 'nl', + 'dutch': 'nl', + 'nor': 'no', + 'norwegian': 'no', + 'pol': 'pl', + 'polish': 'pl', + 'por': 'pt', + 'portuguese': 'pt', + 'ron': 'ro', + 'rum': 'ro', + 'romanian': 'ro', + 'rus': 'ru', + 'russian': 'ru', + 'slk': 'sk', + 'slo': 'sk', + 'slovak': 'sk', + 'swe': 'sv', + 'swedish': 'sv', + 'tur': 'tr', + 'turkish': 'tr', + 'ukr': 'uk', + 'ukrainian': 'uk', + 'zho': 'zh', + 'chi': 'zh', + 'chinese': 'zh', +}; + +String? normalizeBookLanguage(String? language) { + final normalized = language?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + + return _languageAliases[normalized] ?? normalized; +} + +String bookLanguageLabel(String language) { + final normalized = normalizeBookLanguage(language); + if (normalized == null) { + return ''; + } + + return _languageLabels[normalized] ?? language.trim(); +} diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index 4a6d5d4..dbb923d 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_upload_queue.dart'; -import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/services/book_delete_cleanup_service.dart'; import 'package:papyrus/services/book_import_service_stub.dart' @@ -36,7 +36,7 @@ void bulkAddTopics(DataStore dataStore, Set bookIds, List tagIds } /// Change reading status for all selected books. -void bulkChangeStatus(DataStore dataStore, Set bookIds, ReadingStatus status) { +void bulkChangeStatus(DataStore dataStore, Set bookIds, LibraryReadingStatus status) { for (final bookId in bookIds) { final book = dataStore.getBook(bookId); if (book != null) { diff --git a/app/lib/widgets/book/book.dart b/app/lib/widgets/book/book.dart index b4a7925..e336952 100644 --- a/app/lib/widgets/book/book.dart +++ b/app/lib/widgets/book/book.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/models/book.dart' as models; +import 'package:papyrus/providers/enums/library_reading_status.dart'; class Book extends StatefulWidget { final String id; @@ -21,7 +22,7 @@ class _BookState extends State with SingleTickerProviderStateMixin { @override void initState() { super.initState(); - isFinished = widget.data.isFinished; + isFinished = widget.data.readingStatus == LibraryReadingStatus.completed; animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this); animation = ColorTween(begin: Colors.transparent, end: Colors.green[500]).animate(animationController) diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index 3f5d56a..151a010 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -17,6 +17,7 @@ class AnnotationDialog extends StatefulWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, useSafeArea: true, builder: (context) => AnnotationDialog(bookId: bookId, existingAnnotation: existingAnnotation), ); diff --git a/app/lib/widgets/book_details/book_info_grid.dart b/app/lib/widgets/book_details/book_info_grid.dart index 65b2819..650554f 100644 --- a/app/lib/widgets/book_details/book_info_grid.dart +++ b/app/lib/widgets/book_details/book_info_grid.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; /// Information grid for book metadata. @@ -81,7 +82,7 @@ class BookInfoGrid extends StatelessWidget { } // Reading status - if (book.readingStatus != ReadingStatus.notStarted) { + if (book.readingStatus != LibraryReadingStatus.unread) { entries.add(_InfoEntry('Status', book.readingStatus.label)); } diff --git a/app/lib/widgets/book_details/bookmark_dialog.dart b/app/lib/widgets/book_details/bookmark_dialog.dart index 82910f2..449cfb2 100644 --- a/app/lib/widgets/book_details/bookmark_dialog.dart +++ b/app/lib/widgets/book_details/bookmark_dialog.dart @@ -23,6 +23,7 @@ class BookmarkDialog extends StatefulWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), ), diff --git a/app/lib/widgets/book_details/note_dialog.dart b/app/lib/widgets/book_details/note_dialog.dart index 92b42a6..daf218d 100644 --- a/app/lib/widgets/book_details/note_dialog.dart +++ b/app/lib/widgets/book_details/note_dialog.dart @@ -18,6 +18,7 @@ class NoteDialog extends StatelessWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, useSafeArea: true, builder: (context) => _BottomSheetNote(bookId: bookId, existingNote: existingNote), ); diff --git a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart index 4b01261..5b3fa04 100644 --- a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart +++ b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart @@ -110,6 +110,7 @@ class BookmarkNoteSheet extends StatefulWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), ), diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 5a56295..3c0ba92 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Context menu for book actions. -/// Shows a bottom sheet on mobile and a popup menu on desktop. class BookContextMenu { - /// Show the context menu for a book. static void show({ required BuildContext context, required Book book, @@ -17,165 +16,13 @@ class BookContextMenu { VoidCallback? onEdit, VoidCallback? onMoveToShelf, VoidCallback? onManageTopics, - Function(ReadingStatus)? onStatusChange, - VoidCallback? onDownload, - VoidCallback? onDelete, - }) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - if (isDesktop && tapPosition != null) { - _showDesktopMenu( - context: context, - position: tapPosition, - book: book, - isFavorite: isFavorite, - onSelect: onSelect, - onFavoriteToggle: onFavoriteToggle, - onEdit: onEdit, - onMoveToShelf: onMoveToShelf, - onManageTopics: onManageTopics, - onStatusChange: onStatusChange, - onDownload: onDownload, - onDelete: onDelete, - ); - } else { - _showMobileSheet( - context: context, - book: book, - isFavorite: isFavorite, - onSelect: onSelect, - onFavoriteToggle: onFavoriteToggle, - onEdit: onEdit, - onMoveToShelf: onMoveToShelf, - onManageTopics: onManageTopics, - onStatusChange: onStatusChange, - onDownload: onDownload, - onDelete: onDelete, - ); - } - } - - static void _showDesktopMenu({ - required BuildContext context, - required Offset position, - required Book book, - required bool isFavorite, - VoidCallback? onSelect, - VoidCallback? onFavoriteToggle, - VoidCallback? onEdit, - VoidCallback? onMoveToShelf, - VoidCallback? onManageTopics, - Function(ReadingStatus)? onStatusChange, - VoidCallback? onDownload, - VoidCallback? onDelete, - }) { - final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; - final colorScheme = Theme.of(context).colorScheme; - - showMenu( - context: context, - position: RelativeRect.fromRect(Rect.fromLTWH(position.dx, position.dy, 0, 0), Offset.zero & overlay.size), - elevation: AppElevation.level2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - items: [ - const PopupMenuItem( - value: 'select', - height: 40, - child: _MenuItemRow(icon: Icons.checklist, label: 'Select'), - ), - const PopupMenuDivider(), - PopupMenuItem( - value: 'favorite', - height: 40, - child: _MenuItemRow( - icon: isFavorite ? Icons.favorite : Icons.favorite_border, - label: isFavorite ? 'Remove from favorites' : 'Add to favorites', - iconColor: isFavorite ? colorScheme.error : null, - ), - ), - const PopupMenuDivider(), - const PopupMenuItem( - value: 'edit', - height: 40, - child: _MenuItemRow(icon: Icons.edit_outlined, label: 'Edit details'), - ), - const PopupMenuItem( - value: 'shelf', - height: 40, - child: _MenuItemRow(icon: Icons.folder_outlined, label: 'Move to shelf'), - ), - const PopupMenuItem( - value: 'topics', - height: 40, - child: _MenuItemRow(icon: Icons.label_outline, label: 'Manage topics'), - ), - const PopupMenuDivider(), - PopupMenuItem( - value: 'reading', - height: 40, - child: _MenuItemRow(icon: Icons.auto_stories, label: 'Mark as reading', isSelected: book.isReading), - ), - PopupMenuItem( - value: 'finished', - height: 40, - child: _MenuItemRow(icon: Icons.check_circle_outline, label: 'Mark as finished', isSelected: book.isFinished), - ), - const PopupMenuDivider(), - if (!book.isPhysical) - const PopupMenuItem( - value: 'download', - height: 40, - child: _MenuItemRow(icon: Icons.file_download_outlined, label: 'Download'), - ), - const PopupMenuItem( - value: 'delete', - height: 40, - child: _MenuItemRow(icon: Icons.delete_outline, label: 'Delete', isDestructive: true), - ), - ], - ).then((value) { - if (value == null) return; - if (!context.mounted) return; - - switch (value) { - case 'select': - onSelect?.call(); - case 'favorite': - onFavoriteToggle?.call(); - case 'edit': - onEdit?.call(); - case 'shelf': - onMoveToShelf?.call(); - case 'topics': - onManageTopics?.call(); - case 'reading': - onStatusChange?.call(ReadingStatus.inProgress); - case 'finished': - onStatusChange?.call(ReadingStatus.completed); - case 'download': - onDownload?.call(); - case 'delete': - _confirmDelete(context, book, onDelete); - } - }); - } - - static void _showMobileSheet({ - required BuildContext context, - required Book book, - required bool isFavorite, - VoidCallback? onSelect, - VoidCallback? onFavoriteToggle, - VoidCallback? onEdit, - VoidCallback? onMoveToShelf, - VoidCallback? onManageTopics, - Function(ReadingStatus)? onStatusChange, + Function(LibraryReadingStatus)? onStatusChange, VoidCallback? onDownload, VoidCallback? onDelete, }) { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), @@ -220,41 +67,6 @@ class BookContextMenu { } } -/// Menu item row for desktop popup menu. -class _MenuItemRow extends StatelessWidget { - final IconData icon; - final String label; - final bool isDestructive; - final bool isSelected; - final Color? iconColor; - - const _MenuItemRow({ - required this.icon, - required this.label, - this.isDestructive = false, - this.isSelected = false, - this.iconColor, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final color = isDestructive ? colorScheme.error : null; - final effectiveIconColor = iconColor ?? color; - - return Row( - children: [ - Icon(icon, size: IconSizes.action, color: effectiveIconColor), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text(label, style: TextStyle(color: color)), - ), - if (isSelected) Icon(Icons.check, size: IconSizes.small, color: colorScheme.primary), - ], - ); - } -} - /// Bottom sheet for mobile context menu. class _BookContextBottomSheet extends StatelessWidget { final Book book; @@ -264,7 +76,7 @@ class _BookContextBottomSheet extends StatelessWidget { final VoidCallback? onEdit; final VoidCallback? onMoveToShelf; final VoidCallback? onManageTopics; - final Function(ReadingStatus)? onStatusChange; + final Function(LibraryReadingStatus)? onStatusChange; final VoidCallback? onDownload; final VoidCallback? onDelete; @@ -391,29 +203,29 @@ class _BookContextBottomSheet extends StatelessWidget { ), _BottomSheetItem( icon: Icons.auto_stories, - label: 'Currently reading', - isSelected: book.isReading, + label: 'Reading', + isSelected: book.readingStatus == LibraryReadingStatus.inProgress, onTap: () { Navigator.pop(context); - onStatusChange?.call(ReadingStatus.inProgress); + onStatusChange?.call(LibraryReadingStatus.inProgress); }, ), _BottomSheetItem( icon: Icons.check_circle_outline, - label: 'Finished', - isSelected: book.isFinished, + label: 'Completed', + isSelected: book.readingStatus == LibraryReadingStatus.completed, onTap: () { Navigator.pop(context); - onStatusChange?.call(ReadingStatus.completed); + onStatusChange?.call(LibraryReadingStatus.completed); }, ), _BottomSheetItem( icon: Icons.bookmark_outline, label: 'Unread', - isSelected: book.progress == 0 && !book.isFinished, + isSelected: book.readingStatus == LibraryReadingStatus.unread, onTap: () { Navigator.pop(context); - onStatusChange?.call(ReadingStatus.notStarted); + onStatusChange?.call(LibraryReadingStatus.unread); }, ), diff --git a/app/lib/widgets/filter/filter_bottom_sheet.dart b/app/lib/widgets/filter/filter_bottom_sheet.dart deleted file mode 100644 index 42a8878..0000000 --- a/app/lib/widgets/filter/filter_bottom_sheet.dart +++ /dev/null @@ -1,671 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/models/book.dart'; -import 'package:papyrus/themes/design_tokens.dart'; - -/// Filter options available for dropdowns. -class FilterOptions { - final List formats; - final List shelves; - final List topics; - final List statuses; - - const FilterOptions({ - this.formats = const [], - this.shelves = const [], - this.topics = const [], - this.statuses = const ['reading', 'finished', 'unread'], - }); - - factory FilterOptions.fromBooks( - List books, { - List shelfNames = const [], - List topicNames = const [], - }) { - return FilterOptions( - formats: books.map((b) => b.formatLabel.toLowerCase()).toSet().toList()..sort(), - shelves: shelfNames.toList()..sort(), - topics: topicNames.toList()..sort(), - ); - } -} - -/// Callback data when filters are applied. -class AppliedFilters { - final String? author; - final String? format; - final String? shelf; - final String? topic; - final String? status; - final bool filterReading; - final bool filterFavorites; - final bool filterFinished; - final bool filterUnread; - final RangeValues? progressRange; - - const AppliedFilters({ - this.author, - this.format, - this.shelf, - this.topic, - this.status, - this.filterReading = false, - this.filterFavorites = false, - this.filterFinished = false, - this.filterUnread = false, - this.progressRange, - }); - - /// Convert to query string. - String toQueryString() { - final parts = []; - - if (author != null && author!.isNotEmpty) { - parts.add('author:"$author"'); - } - if (format != null) { - parts.add('format:$format'); - } - if (shelf != null) { - parts.add('shelf:"$shelf"'); - } - if (topic != null) { - parts.add('topic:"$topic"'); - } - if (status != null) { - parts.add('status:$status'); - } - if (progressRange != null) { - if (progressRange!.start > 0) { - parts.add('progress:>${progressRange!.start.toInt()}'); - } - if (progressRange!.end < 100) { - parts.add('progress:<${progressRange!.end.toInt()}'); - } - } - - return parts.join(' '); - } - - bool get hasFilters { - return author != null || - format != null || - shelf != null || - topic != null || - status != null || - filterReading || - filterFavorites || - filterFinished || - filterUnread || - progressRange != null; - } - - /// Parse a search query string to extract field values back into - /// an AppliedFilters, merging with explicitly provided values. - factory AppliedFilters.fromQueryString( - String query, { - bool filterReading = false, - bool filterFavorites = false, - bool filterFinished = false, - bool filterUnread = false, - String? shelf, - String? topic, - }) { - String? author; - String? format; - String? status; - double progressMin = 0; - double progressMax = 100; - bool hasProgress = false; - - // Tokenize respecting quotes - final tokens = _tokenize(query); - for (final token in tokens) { - final colonIndex = token.indexOf(':'); - if (colonIndex <= 0) continue; - - final field = token.substring(0, colonIndex).toLowerCase(); - var value = token.substring(colonIndex + 1); - - // Strip quotes - if (value.startsWith('"') && value.endsWith('"') && value.length > 1) { - value = value.substring(1, value.length - 1); - } - - switch (field) { - case 'author': - author = value; - case 'format': - format = value; - case 'shelf': - shelf ??= value; - case 'topic': - topic ??= value; - case 'status': - status = value; - case 'progress': - hasProgress = true; - if (value.startsWith('>')) { - progressMin = double.tryParse(value.substring(1)) ?? 0; - } else if (value.startsWith('<')) { - progressMax = double.tryParse(value.substring(1)) ?? 100; - } - } - } - - return AppliedFilters( - author: author, - format: format, - shelf: shelf, - topic: topic, - status: status, - filterReading: filterReading, - filterFavorites: filterFavorites, - filterFinished: filterFinished, - filterUnread: filterUnread, - progressRange: hasProgress ? RangeValues(progressMin, progressMax) : null, - ); - } - - /// Tokenize a query string respecting quoted phrases. - static List _tokenize(String input) { - final tokens = []; - final buffer = StringBuffer(); - bool inQuotes = false; - - for (int i = 0; i < input.length; i++) { - final char = input[i]; - if (char == '"') { - inQuotes = !inQuotes; - buffer.write(char); - } else if (char == ' ' && !inQuotes) { - if (buffer.isNotEmpty) { - tokens.add(buffer.toString()); - buffer.clear(); - } - } else { - buffer.write(char); - } - } - if (buffer.isNotEmpty) { - tokens.add(buffer.toString()); - } - return tokens; - } -} - -/// Bottom sheet for building advanced filters visually. -class FilterBottomSheet extends StatefulWidget { - /// Available filter options. - final FilterOptions filterOptions; - - /// Callback when filters are applied. - final ValueChanged? onApply; - - /// Callback when reset is requested. - final VoidCallback? onReset; - - /// Initial filter values. - final AppliedFilters? initialFilters; - - const FilterBottomSheet({super.key, required this.filterOptions, this.onApply, this.onReset, this.initialFilters}); - - @override - State createState() => _FilterBottomSheetState(); - - /// Show the filter bottom sheet. - static Future show( - BuildContext context, { - required FilterOptions filterOptions, - AppliedFilters? initialFilters, - }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.4, - maxChildSize: 0.95, - builder: (context, scrollController) => Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), - ), - child: FilterBottomSheet( - filterOptions: filterOptions, - initialFilters: initialFilters, - onApply: (filters) => Navigator.of(context).pop(filters), - onReset: () => Navigator.of(context).pop(null), - ), - ), - ), - ); - } -} - -class _FilterBottomSheetState extends State { - final TextEditingController _authorController = TextEditingController(); - - String? _selectedFormat; - String? _selectedShelf; - String? _selectedTopic; - String? _selectedStatus; - - bool _filterReading = false; - bool _filterFavorites = false; - bool _filterFinished = false; - bool _filterUnread = false; - - RangeValues _progressRange = const RangeValues(0, 100); - bool _useProgressFilter = false; - - @override - void initState() { - super.initState(); - _loadInitialFilters(); - } - - void _loadInitialFilters() { - final initial = widget.initialFilters; - if (initial != null) { - _authorController.text = initial.author ?? ''; - _selectedFormat = initial.format; - _selectedShelf = initial.shelf; - _selectedTopic = initial.topic; - _selectedStatus = initial.status; - _filterReading = initial.filterReading; - _filterFavorites = initial.filterFavorites; - _filterFinished = initial.filterFinished; - _filterUnread = initial.filterUnread; - if (initial.progressRange != null) { - _progressRange = initial.progressRange!; - _useProgressFilter = true; - } - } - } - - @override - void dispose() { - _authorController.dispose(); - super.dispose(); - } - - void _applyFilters() { - final filters = AppliedFilters( - author: _authorController.text.isNotEmpty ? _authorController.text : null, - format: _selectedFormat, - shelf: _selectedShelf, - topic: _selectedTopic, - status: _selectedStatus, - filterReading: _filterReading, - filterFavorites: _filterFavorites, - filterFinished: _filterFinished, - filterUnread: _filterUnread, - progressRange: _useProgressFilter ? _progressRange : null, - ); - widget.onApply?.call(filters); - } - - void _resetFilters() { - setState(() { - _authorController.clear(); - _selectedFormat = null; - _selectedShelf = null; - _selectedTopic = null; - _selectedStatus = null; - _filterReading = false; - _filterFavorites = false; - _filterFinished = false; - _filterUnread = false; - _progressRange = const RangeValues(0, 100); - _useProgressFilter = false; - }); - // Apply empty filters to actually clear them - widget.onApply?.call(const AppliedFilters()); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - _buildHeader(context), - const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader('Quick filters'), - const SizedBox(height: Spacing.sm), - _buildQuickFilters(), - const SizedBox(height: Spacing.md), - const Divider(), - const SizedBox(height: Spacing.md), - _buildSectionHeader('Filter by'), - const SizedBox(height: Spacing.md), - _buildAdvancedFilters(context), - _buildProgressSlider(context), - ], - ), - ), - ), - _buildActionBar(context, colorScheme), - ], - ); - } - - Widget _buildHeader(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - Container( - margin: const EdgeInsets.only(top: Spacing.sm), - width: 32, - height: 4, - decoration: BoxDecoration(color: colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.xs), - child: Row( - children: [ - Text('Filters', style: Theme.of(context).textTheme.titleLarge), - const Spacer(), - IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()), - ], - ), - ), - ], - ); - } - - Widget _buildAdvancedFilters(BuildContext context) { - return Column( - children: [ - // Author field - _buildFilterField( - label: 'Author', - icon: Icons.person_outline, - child: TextField( - controller: _authorController, - decoration: const InputDecoration(hintText: 'Enter author name...', border: OutlineInputBorder()), - onChanged: (_) => setState(() {}), - ), - ), - - // Format dropdown - _buildFilterField( - label: 'Format', - icon: Icons.book_outlined, - child: DropdownMenu( - initialSelection: _selectedFormat, - expandedInsets: EdgeInsets.zero, - hintText: 'Any format', - dropdownMenuEntries: [ - const DropdownMenuEntry(value: '', label: 'Any format'), - ...widget.filterOptions.formats.map((f) => DropdownMenuEntry(value: f, label: f.toUpperCase())), - ], - onSelected: (v) => setState(() => _selectedFormat = v?.isEmpty == true ? null : v), - ), - ), - - // Shelf dropdown - _buildFilterField( - label: 'Shelf', - icon: Icons.folder_outlined, - child: DropdownMenu( - initialSelection: _selectedShelf, - expandedInsets: EdgeInsets.zero, - hintText: 'Any shelf', - dropdownMenuEntries: [ - const DropdownMenuEntry(value: '', label: 'Any shelf'), - ...widget.filterOptions.shelves.map((s) => DropdownMenuEntry(value: s, label: s)), - ], - onSelected: (v) => setState(() => _selectedShelf = v?.isEmpty == true ? null : v), - ), - ), - - // Topic dropdown - _buildFilterField( - label: 'Topic', - icon: Icons.label_outline, - child: DropdownMenu( - initialSelection: _selectedTopic, - expandedInsets: EdgeInsets.zero, - hintText: 'Any topic', - dropdownMenuEntries: [ - const DropdownMenuEntry(value: '', label: 'Any topic'), - ...widget.filterOptions.topics.map((t) => DropdownMenuEntry(value: t, label: t)), - ], - onSelected: (v) => setState(() => _selectedTopic = v?.isEmpty == true ? null : v), - ), - ), - - // Status dropdown - _buildFilterField( - label: 'Status', - icon: Icons.schedule, - child: DropdownMenu( - initialSelection: _selectedStatus, - expandedInsets: EdgeInsets.zero, - hintText: 'Any status', - dropdownMenuEntries: const [ - DropdownMenuEntry(value: '', label: 'Any status'), - DropdownMenuEntry(value: 'reading', label: 'Currently reading'), - DropdownMenuEntry(value: 'finished', label: 'Finished'), - DropdownMenuEntry(value: 'unread', label: 'Unread'), - ], - onSelected: (v) => setState(() => _selectedStatus = v?.isEmpty == true ? null : v), - ), - ), - ], - ); - } - - Widget _buildProgressSlider(BuildContext context) { - return _buildFilterField( - label: 'Progress', - icon: Icons.show_chart, - child: Column( - children: [ - const SizedBox(height: Spacing.sm), - Row( - children: [ - SizedBox( - width: 36, - child: Text('${_progressRange.start.toInt()}%', style: Theme.of(context).textTheme.bodySmall), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith(overlayShape: SliderComponentShape.noOverlay), - child: RangeSlider( - values: _progressRange, - min: 0, - max: 100, - divisions: 20, - onChanged: (v) => setState(() { - _progressRange = v; - _useProgressFilter = true; - }), - ), - ), - ), - SizedBox( - width: 36, - child: Text( - '${_progressRange.end.toInt()}%', - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.end, - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildActionBar(BuildContext context, ColorScheme colorScheme) { - return Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(top: BorderSide(color: colorScheme.outlineVariant)), - ), - child: SafeArea( - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 48, - child: OutlinedButton( - onPressed: _resetFilters, - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - ), - child: const Text('Reset'), - ), - ), - ), - const SizedBox(width: Spacing.sm), - Expanded( - flex: 2, - child: SizedBox( - height: 48, - child: FilledButton( - onPressed: _applyFilters, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - ), - child: const Text('Apply filters'), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildSectionHeader(String title) { - final colorScheme = Theme.of(context).colorScheme; - - return Text( - title, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - ); - } - - Widget _buildQuickFilters() { - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - _QuickFilterCheckbox( - label: 'Reading', - icon: Icons.auto_stories, - value: _filterReading, - onChanged: (v) => setState(() => _filterReading = v ?? false), - ), - _QuickFilterCheckbox( - label: 'Favorites', - icon: Icons.favorite, - value: _filterFavorites, - onChanged: (v) => setState(() => _filterFavorites = v ?? false), - ), - _QuickFilterCheckbox( - label: 'Finished', - icon: Icons.check_circle, - value: _filterFinished, - onChanged: (v) => setState(() => _filterFinished = v ?? false), - ), - _QuickFilterCheckbox( - label: 'Unread', - icon: Icons.book, - value: _filterUnread, - onChanged: (v) => setState(() => _filterUnread = v ?? false), - ), - ], - ); - } - - Widget _buildFilterField({required String label, required IconData icon, required Widget child}) { - final colorScheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: IconSizes.small, color: colorScheme.onSurfaceVariant), - const SizedBox(width: Spacing.sm), - Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - const SizedBox(height: Spacing.sm), - child, - ], - ), - ); - } -} - -class _QuickFilterCheckbox extends StatelessWidget { - final String label; - final IconData icon; - final bool value; - final ValueChanged? onChanged; - - const _QuickFilterCheckbox({required this.label, required this.icon, required this.value, this.onChanged}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return InkWell( - onTap: () => onChanged?.call(!value), - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - width: (MediaQuery.of(context).size.width - Spacing.md * 3) / 2, - height: 44, - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - decoration: BoxDecoration( - color: value ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.md), - border: value ? null : Border.all(color: colorScheme.outlineVariant), - ), - child: Row( - children: [ - Checkbox(value: value, onChanged: onChanged, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), - Icon( - icon, - size: IconSizes.small, - color: value ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: Spacing.sm), - Text( - label, - style: TextStyle( - color: value ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, - fontWeight: value ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/widgets/filter/filter_dialog.dart b/app/lib/widgets/filter/filter_dialog.dart deleted file mode 100644 index bb44b99..0000000 --- a/app/lib/widgets/filter/filter_dialog.dart +++ /dev/null @@ -1,295 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/filter/filter_bottom_sheet.dart'; - -/// Dialog for building filters visually (used on desktop). -/// Returns the same [AppliedFilters] as [FilterBottomSheet]. -class FilterDialog extends StatefulWidget { - /// Available filter options for dropdowns. - final FilterOptions filterOptions; - - /// Initial filter values to populate the dialog. - final AppliedFilters? initialFilters; - - const FilterDialog({super.key, required this.filterOptions, this.initialFilters}); - - /// Show the filter dialog and return the applied filters. - static Future show( - BuildContext context, { - required FilterOptions filterOptions, - AppliedFilters? initialFilters, - }) { - return showDialog( - context: context, - builder: (context) => FilterDialog(filterOptions: filterOptions, initialFilters: initialFilters), - ); - } - - @override - State createState() => _FilterDialogState(); -} - -class _FilterDialogState extends State { - String? _selectedAuthor; - String? _selectedFormat; - String? _selectedShelf; - String? _selectedTopic; - String? _selectedStatus; - - // Quick filters - bool _filterReading = false; - bool _filterFavorites = false; - bool _filterFinished = false; - bool _filterUnread = false; - - // Progress filter - RangeValues _progressRange = const RangeValues(0, 100); - bool _useProgressFilter = false; - - @override - void initState() { - super.initState(); - _loadInitialFilters(); - } - - void _loadInitialFilters() { - final initial = widget.initialFilters; - if (initial == null) return; - - setState(() { - _selectedAuthor = initial.author; - _selectedFormat = initial.format; - _selectedShelf = initial.shelf; - _selectedTopic = initial.topic; - _selectedStatus = initial.status; - _filterReading = initial.filterReading; - _filterFavorites = initial.filterFavorites; - _filterFinished = initial.filterFinished; - _filterUnread = initial.filterUnread; - if (initial.progressRange != null) { - _progressRange = initial.progressRange!; - _useProgressFilter = true; - } - }); - } - - void _applyFilters() { - final result = AppliedFilters( - author: _selectedAuthor?.isNotEmpty == true ? _selectedAuthor : null, - format: _selectedFormat, - shelf: _selectedShelf, - topic: _selectedTopic, - status: _selectedStatus, - filterReading: _filterReading, - filterFavorites: _filterFavorites, - filterFinished: _filterFinished, - filterUnread: _filterUnread, - progressRange: _useProgressFilter ? _progressRange : null, - ); - Navigator.of(context).pop(result); - } - - void _clearFilters() { - setState(() { - _selectedAuthor = null; - _selectedFormat = null; - _selectedShelf = null; - _selectedTopic = null; - _selectedStatus = null; - _filterReading = false; - _filterFavorites = false; - _filterFinished = false; - _filterUnread = false; - _progressRange = const RangeValues(0, 100); - _useProgressFilter = false; - }); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - final formats = widget.filterOptions.formats; - final shelves = widget.filterOptions.shelves; - final topics = widget.filterOptions.topics; - - return AlertDialog( - title: const Text('Advanced filters'), - content: SizedBox( - width: 480, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quick filters section - Text( - 'Quick filters', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold), - ), - const SizedBox(height: Spacing.sm), - Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - FilterChip( - label: const Text('Reading'), - selected: _filterReading, - onSelected: (v) => setState(() => _filterReading = v), - ), - FilterChip( - label: const Text('Favorites'), - selected: _filterFavorites, - onSelected: (v) => setState(() => _filterFavorites = v), - ), - FilterChip( - label: const Text('Finished'), - selected: _filterFinished, - onSelected: (v) => setState(() => _filterFinished = v), - ), - FilterChip( - label: const Text('Unread'), - selected: _filterUnread, - onSelected: (v) => setState(() => _filterUnread = v), - ), - ], - ), - - const SizedBox(height: Spacing.sm), - const Divider(), - const SizedBox(height: Spacing.sm), - - // Advanced filters section - Text( - 'Advanced filters', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold), - ), - const SizedBox(height: Spacing.md), - - // Author filter - TextField( - decoration: const InputDecoration( - labelText: 'Author', - hintText: 'Enter author name...', - prefixIcon: Icon(Icons.person_outline), - ), - controller: TextEditingController(text: _selectedAuthor), - onChanged: (v) => _selectedAuthor = v, - ), - const SizedBox(height: Spacing.md), - - // Format dropdown - DropdownButtonFormField( - initialValue: _selectedFormat, - decoration: const InputDecoration(labelText: 'Format', prefixIcon: Icon(Icons.book_outlined)), - items: [ - const DropdownMenuItem(value: null, child: Text('Any format')), - ...formats.map((f) => DropdownMenuItem(value: f.toLowerCase(), child: Text(f.toUpperCase()))), - ], - onChanged: (v) => setState(() => _selectedFormat = v), - ), - const SizedBox(height: Spacing.md), - - // Shelf dropdown - DropdownButtonFormField( - initialValue: _selectedShelf, - decoration: const InputDecoration(labelText: 'Shelf', prefixIcon: Icon(Icons.folder_outlined)), - items: [ - const DropdownMenuItem(value: null, child: Text('Any shelf')), - ...shelves.map((s) => DropdownMenuItem(value: s, child: Text(s))), - ], - onChanged: (v) => setState(() => _selectedShelf = v), - ), - const SizedBox(height: Spacing.md), - - // Topic dropdown - DropdownButtonFormField( - initialValue: _selectedTopic, - decoration: const InputDecoration(labelText: 'Topic', prefixIcon: Icon(Icons.label_outline)), - items: [ - const DropdownMenuItem(value: null, child: Text('Any topic')), - ...topics.map((t) => DropdownMenuItem(value: t, child: Text(t))), - ], - onChanged: (v) => setState(() => _selectedTopic = v), - ), - const SizedBox(height: Spacing.md), - - // Status dropdown - DropdownButtonFormField( - initialValue: _selectedStatus, - decoration: const InputDecoration(labelText: 'Status', prefixIcon: Icon(Icons.schedule)), - items: const [ - DropdownMenuItem(value: null, child: Text('Any status')), - DropdownMenuItem(value: 'reading', child: Text('Currently reading')), - DropdownMenuItem(value: 'finished', child: Text('Finished')), - DropdownMenuItem(value: 'unread', child: Text('Unread')), - ], - onChanged: (v) => setState(() => _selectedStatus = v), - ), - const SizedBox(height: Spacing.md), - - // Progress range slider - Row( - children: [ - Icon(Icons.show_chart, size: IconSizes.small, color: colorScheme.onSurfaceVariant), - const SizedBox(width: Spacing.sm), - Text( - 'Progress', - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - const SizedBox(height: Spacing.sm), - Row( - children: [ - SizedBox( - width: 36, - child: Text('${_progressRange.start.toInt()}%', style: Theme.of(context).textTheme.bodySmall), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith(overlayShape: SliderComponentShape.noOverlay), - child: RangeSlider( - values: _progressRange, - min: 0, - max: 100, - divisions: 20, - onChanged: (v) => setState(() { - _progressRange = v; - _useProgressFilter = true; - }), - ), - ), - ), - SizedBox( - width: 36, - child: Text( - '${_progressRange.end.toInt()}%', - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.end, - ), - ), - ], - ), - ], - ), - ), - ), - actions: [ - Row( - children: [ - TextButton(onPressed: _clearFilters, child: const Text('Clear all')), - const Spacer(), - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), - const SizedBox(width: Spacing.sm), - FilledButton(onPressed: _applyFilters, child: const Text('Apply filters')), - ], - ), - ], - ); - } -} diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 63d1b4c..6c78e67 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/book_actions.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; @@ -189,7 +190,9 @@ class _BookCardState extends State { LinearProgressIndicator( value: widget.book.progress, backgroundColor: colorScheme.surfaceContainerHighest, - color: widget.book.isFinished ? colorScheme.tertiary : colorScheme.primary, + color: widget.book.readingStatus == LibraryReadingStatus.completed + ? colorScheme.tertiary + : colorScheme.primary, minHeight: 3, ), // Title and author diff --git a/app/lib/widgets/library/book_grid.dart b/app/lib/widgets/library/book_grid.dart index a9f48f9..2765fec 100644 --- a/app/lib/widgets/library/book_grid.dart +++ b/app/lib/widgets/library/book_grid.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; import 'package:papyrus/widgets/library/book_card.dart'; import 'package:provider/provider.dart'; -/// Responsive grid for displaying books. -/// - Mobile: 2 columns with 8px gap -/// - Tablet: 3-4 columns with 12px gap -/// - Desktop: 5 columns with 16px gap +typedef _GridLayout = ({int crossAxisCount, double spacing, double childAspectRatio}); + class BookGrid extends StatelessWidget { final List books; + final LibraryViewMode libraryViewMode; final void Function(Book book)? onBookTap; final EdgeInsets? padding; final Map acquisitionJobsByBookId; @@ -24,6 +24,7 @@ class BookGrid extends StatelessWidget { const BookGrid({ super.key, required this.books, + required this.libraryViewMode, this.onBookTap, this.padding, this.acquisitionJobsByBookId = const {}, @@ -33,33 +34,31 @@ class BookGrid extends StatelessWidget { this.onAcquisitionSelectionToggle, }); - @override - Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - - // Calculate columns and spacing based on screen width - int crossAxisCount; - double spacing; - double childAspectRatio; - - if (screenWidth >= Breakpoints.desktopLarge) { - crossAxisCount = 6; - spacing = Spacing.md; - childAspectRatio = 0.55; - } else if (screenWidth >= Breakpoints.desktopSmall) { - crossAxisCount = 5; - spacing = Spacing.md; - childAspectRatio = 0.55; - } else if (screenWidth >= Breakpoints.tablet) { - crossAxisCount = 4; - spacing = Spacing.sm + 4; - childAspectRatio = 0.55; - } else { - crossAxisCount = 2; - spacing = Spacing.sm; - childAspectRatio = 0.58; + _GridLayout _resolveGridLayout({required double width, required LibraryViewMode viewMode}) { + if (viewMode == LibraryViewMode.list) { + throw ArgumentError('List mode does not use a grid layout'); + } + + final isLargeGrid = viewMode == LibraryViewMode.largeGrid; + + if (width >= Breakpoints.desktopLarge) { + return (crossAxisCount: isLargeGrid ? 4 : 6, spacing: Spacing.md, childAspectRatio: 0.55); + } + + if (width >= Breakpoints.desktopSmall) { + return (crossAxisCount: isLargeGrid ? 3 : 5, spacing: Spacing.md, childAspectRatio: 0.55); + } + + if (width >= Breakpoints.tablet) { + return (crossAxisCount: isLargeGrid ? 3 : 4, spacing: Spacing.sm + 4, childAspectRatio: 0.55); } + return (crossAxisCount: 2, spacing: Spacing.sm, childAspectRatio: 0.58); + } + + @override + Widget build(BuildContext context) { + final layout = _resolveGridLayout(width: MediaQuery.sizeOf(context).width, viewMode: libraryViewMode); final libraryProvider = context.watch(); final bookIds = books.map((book) => book.id).toSet(); final placeholderJobsByBookId = {}; @@ -106,12 +105,14 @@ class BookGrid extends StatelessWidget { removeTop: true, child: GridView.builder( padding: padding ?? const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), + // Flutter 3.41 compatibility; replaced by scrollCacheExtent in 3.42+. + // ignore: deprecated_member_use cacheExtent: 200, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - childAspectRatio: childAspectRatio, + crossAxisCount: layout.crossAxisCount, + mainAxisSpacing: layout.spacing, + crossAxisSpacing: layout.spacing, + childAspectRatio: layout.childAspectRatio, ), itemCount: books.length + orphanJobs.length, itemBuilder: (context, index) { diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index 7ddef39..58eed41 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/book_actions.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; @@ -158,7 +159,9 @@ class _BookListItemState extends State { child: LinearProgressIndicator( value: widget.book.progress, backgroundColor: colorScheme.surfaceContainerHighest, - color: widget.book.isFinished ? colorScheme.tertiary : colorScheme.primary, + color: widget.book.readingStatus == LibraryReadingStatus.completed + ? colorScheme.tertiary + : colorScheme.primary, minHeight: 3, ), ), diff --git a/app/lib/widgets/library/bulk_status_sheet.dart b/app/lib/widgets/library/bulk_status_sheet.dart index 317b4cc..6537d9d 100644 --- a/app/lib/widgets/library/bulk_status_sheet.dart +++ b/app/lib/widgets/library/bulk_status_sheet.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/text_utils.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; final statusTiles = [ - (icon: Icons.auto_stories, status: ReadingStatus.inProgress, title: "in progress"), - (icon: Icons.check_circle_outline, status: ReadingStatus.completed, title: "finished"), - (icon: Icons.bookmark_add_outlined, status: ReadingStatus.notStarted, title: "unread"), + (icon: Icons.auto_stories, status: LibraryReadingStatus.inProgress, title: "in progress"), + (icon: Icons.check_circle_outline, status: LibraryReadingStatus.completed, title: "finished"), + (icon: Icons.bookmark_add_outlined, status: LibraryReadingStatus.unread, title: "unread"), ]; /// Bottom sheet for changing reading status of multiple books. class BulkStatusSheet extends StatelessWidget { final int bookCount; - final void Function(ReadingStatus status) onStatusSelected; + final void Function(LibraryReadingStatus status) onStatusSelected; const BulkStatusSheet({super.key, required this.bookCount, required this.onStatusSelected}); @@ -21,7 +21,7 @@ class BulkStatusSheet extends StatelessWidget { static Future show( BuildContext context, { required int bookCount, - required void Function(ReadingStatus status) onStatusSelected, + required void Function(LibraryReadingStatus status) onStatusSelected, }) { return showModalBottomSheet( context: context, diff --git a/app/lib/widgets/library/eink_tab_filter.dart b/app/lib/widgets/library/eink_tab_filter.dart deleted file mode 100644 index d3af6a7..0000000 --- a/app/lib/widgets/library/eink_tab_filter.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/providers/library_provider.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:provider/provider.dart'; - -/// E-ink optimized tab-based filter for library content. -/// High contrast, large touch targets, no animations. -class EinkTabFilter extends StatelessWidget { - const EinkTabFilter({super.key}); - - @override - Widget build(BuildContext context) { - final libraryProvider = context.watch(); - final colorScheme = Theme.of(context).colorScheme; - - final tabs = [ - _TabData(type: LibraryFilterType.all, label: 'All'), - _TabData(type: LibraryFilterType.shelves, label: 'Shelves'), - _TabData(type: LibraryFilterType.topics, label: 'Topics'), - _TabData(type: LibraryFilterType.favorites, label: 'Favorites'), - ]; - - return Container( - height: TouchTargets.einkMin, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: colorScheme.outline, width: BorderWidths.einkDefault), - ), - ), - child: Row( - children: tabs.map((tab) { - final isSelected = libraryProvider.isFilterActive(tab.type); - - return Expanded( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => libraryProvider.setFilter(tab.type), - child: Container( - decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : Colors.transparent, - border: Border(right: BorderSide(color: colorScheme.outline, width: 1)), - ), - child: Center( - child: Text( - tab.label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isSelected ? colorScheme.onPrimary : colorScheme.onSurface, - fontWeight: FontWeight.bold, - letterSpacing: 1.0, - ), - ), - ), - ), - ), - ), - ); - }).toList(), - ), - ); - } -} - -class _TabData { - final LibraryFilterType type; - final String label; - - const _TabData({required this.type, required this.label}); -} diff --git a/app/lib/widgets/library/library_advanced_filter_sheet.dart b/app/lib/widgets/library/library_advanced_filter_sheet.dart new file mode 100644 index 0000000..2c76415 --- /dev/null +++ b/app/lib/widgets/library/library_advanced_filter_sheet.dart @@ -0,0 +1,1044 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/library_filter_options.dart'; +import 'package:papyrus/models/library_filters.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/utils/book_language.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; + +bool _isEinkTheme(ThemeData theme) { + final border = theme.inputDecorationTheme.border; + return border is OutlineInputBorder && + border.borderRadius == BorderRadius.zero && + border.borderSide.width >= BorderWidths.einkDefault; +} + +class LibraryAdvancedFilterSheet extends StatefulWidget { + final LibraryProvider libraryProvider; + final DataStore dataStore; + final ScrollController scrollController; + final List? sourceBooks; + final LibraryFilterOptions? filterOptions; + + const LibraryAdvancedFilterSheet({ + super.key, + required this.libraryProvider, + required this.dataStore, + required this.scrollController, + this.sourceBooks, + this.filterOptions, + }); + + static Future show( + BuildContext context, { + required LibraryProvider libraryProvider, + required DataStore dataStore, + List? sourceBooks, + LibraryFilterOptions? filterOptions, + }) { + return showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.85, + minChildSize: 0.55, + maxChildSize: 1, + snap: true, + snapSizes: const [0.55, 0.85, 1], + builder: (context, scrollController) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + child: LibraryAdvancedFilterSheet( + libraryProvider: libraryProvider, + dataStore: dataStore, + scrollController: scrollController, + sourceBooks: sourceBooks, + filterOptions: filterOptions, + ), + ), + ); + }, + ); + }, + ); + } + + @override + State createState() => _LibraryAdvancedFilterSheetState(); +} + +class _LibraryAdvancedFilterSheetState extends State { + late LibraryFilters _draft = widget.libraryProvider.filters; + late final List _sourceBooks = widget.sourceBooks ?? widget.dataStore.books; + late final LibraryFilterOptions _options = + widget.filterOptions ?? + (widget.sourceBooks == null + ? LibraryFilterOptions.fromDataStore(widget.dataStore) + : LibraryFilterOptions.fromDataStore(widget.dataStore, books: _sourceBooks)); + + int get _matchingBookCount { + return widget.libraryProvider.filterBooks(_sourceBooks, dataStore: widget.dataStore, filters: _draft).length; + } + + void _updateDraft(LibraryFilters filters) { + setState(() => _draft = filters); + } + + void _resetDraft() { + _updateDraft(LibraryFilters()); + } + + void _applyDraft() { + Navigator.of(context).pop(_draft); + } + + List> _withSelectedOptions( + List> options, + Set selectedValues, + String Function(T value) labelForValue, + ) { + return [ + ...options, + for (final value in selectedValues) + if (!options.any((option) => option.value == value)) + LibraryFilterOption(value: value, label: labelForValue(value)), + ]; + } + + String _authorLabel(String value) { + for (final book in widget.dataStore.books) { + for (final author in [book.author, ...book.coAuthors]) { + if (author.trim().toLowerCase() == value) return author.trim(); + } + } + return 'Unknown author ($value)'; + } + + String _languageLabel(String value) { + for (final book in widget.dataStore.books) { + if (normalizeBookLanguage(book.language) == value) return bookLanguageLabel(book.language ?? value); + } + final label = bookLanguageLabel(value); + return label == value ? 'Unknown language ($value)' : label; + } + + String _bookFieldLabel(String value, String? Function(Book book) field, String fieldName) { + for (final book in widget.dataStore.books) { + final label = field(book)?.trim(); + if (label != null && label.toLowerCase() == value) return label; + } + return 'Unknown $fieldName ($value)'; + } + + String _shelfLabel(String value) { + for (final shelf in widget.dataStore.shelves) { + if (shelf.id == value) return shelf.name; + } + return 'Unknown shelf ($value)'; + } + + String _topicLabel(String value) { + for (final topic in widget.dataStore.tags) { + if (topic.id == value) return topic.name; + } + return 'Unknown topic ($value)'; + } + + @override + Widget build(BuildContext context) { + final authorOptions = _withSelectedOptions(_options.authors, _draft.authors, _authorLabel); + final languageOptions = _withSelectedOptions(_options.languages, _draft.languages, _languageLabel); + final publisherOptions = _withSelectedOptions( + _options.publishers, + _draft.publishers, + (value) => _bookFieldLabel(value, (book) => book.publisher, 'publisher'), + ); + final formatOptions = _withSelectedOptions( + _options.formats, + _draft.formats, + (value) => _bookFieldLabel(value, (book) => book.formatLabel, 'format'), + ); + final seriesOptions = _withSelectedOptions( + _options.series, + _draft.seriesNames, + (value) => _bookFieldLabel(value, (book) => book.seriesName, 'series'), + ); + final shelfOptions = _withSelectedOptions(_options.shelves, _draft.shelfIds, _shelfLabel); + final topicOptions = _withSelectedOptions(_options.topics, _draft.topicIds, _topicLabel); + final readingStatusOptions = _withSelectedOptions( + _options.readingStatuses, + _draft.statuses, + (status) => status.label, + ); + final availableRatings = {..._options.ratings, ..._draft.ratings}.toList()..sort(); + final showUnrated = _options.hasUnrated || _draft.includeUnrated; + final hasMetadataOptions = + authorOptions.isNotEmpty || + languageOptions.isNotEmpty || + formatOptions.isNotEmpty || + publisherOptions.isNotEmpty || + seriesOptions.isNotEmpty; + final hasOrganizationOptions = shelfOptions.isNotEmpty || topicOptions.isNotEmpty; + final hasRatingOptions = availableRatings.isNotEmpty || showUnrated; + + return Column( + children: [ + _buildHeader(context), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + controller: widget.scrollController, + padding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.sm, Spacing.lg, Spacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasMetadataOptions) ...[ + const _SectionHeader(title: 'Metadata'), + if (authorOptions.isNotEmpty) + _SearchableFacet( + label: 'Authors', + options: authorOptions, + selectedValues: _draft.authors, + onChanged: (values) => _updateDraft(_draft.copyWith(authors: values)), + ), + if (languageOptions.isNotEmpty) + _SearchableFacet( + label: 'Languages', + options: languageOptions, + selectedValues: _draft.languages, + onChanged: (values) => _updateDraft(_draft.copyWith(languages: values)), + ), + if (publisherOptions.isNotEmpty) + _SearchableFacet( + label: 'Publishers', + options: publisherOptions, + selectedValues: _draft.publishers, + onChanged: (values) => _updateDraft(_draft.copyWith(publishers: values)), + ), + if (formatOptions.isNotEmpty) + _SmallFacet( + label: 'Formats', + options: formatOptions, + selectedValues: _draft.formats, + onChanged: (values) => _updateDraft(_draft.copyWith(formats: values)), + ), + if (seriesOptions.isNotEmpty) + _SearchableFacet( + label: 'Series', + options: seriesOptions, + selectedValues: _draft.seriesNames, + onChanged: (values) => _updateDraft(_draft.copyWith(seriesNames: values)), + ), + ], + if (hasOrganizationOptions) ...[ + _SectionHeader(title: 'Organization', dividerBefore: hasMetadataOptions), + if (shelfOptions.isNotEmpty) + _SearchableFacet( + label: 'Shelves', + options: shelfOptions, + selectedValues: _draft.shelfIds, + onChanged: (values) => _updateDraft(_draft.copyWith(shelfIds: values)), + ), + if (topicOptions.isNotEmpty) + _SearchableFacet( + label: 'Topics', + options: topicOptions, + selectedValues: _draft.topicIds, + onChanged: (values) => _updateDraft(_draft.copyWith(topicIds: values)), + ), + ], + _SectionHeader(title: 'Reading', dividerBefore: hasMetadataOptions || hasOrganizationOptions), + if (readingStatusOptions.isNotEmpty) + _SmallFacet( + label: 'Reading status', + showSummary: false, + options: readingStatusOptions, + selectedValues: _draft.statuses, + onChanged: (values) => _updateDraft(_draft.copyWith(statuses: values)), + ), + _FavoriteFilterField( + value: _draft.favoriteFilter, + onChanged: (value) => _updateDraft(_draft.copyWith(favoriteFilter: value)), + ), + _ProgressFilterField( + value: _draft.progressRange, + onChanged: (value) => _updateDraft(_draft.copyWith(progressRange: value)), + ), + if (hasRatingOptions) + _RatingFilterField( + ratings: _draft.ratings, + includeUnrated: _draft.includeUnrated, + availableRatings: availableRatings, + showUnrated: showUnrated, + onChanged: (ratings, includeUnrated) { + _updateDraft(_draft.copyWith(ratings: ratings, includeUnrated: includeUnrated)); + }, + ), + const _SectionHeader(title: 'Dates', dividerBefore: true), + _DateRangeField( + label: 'Publication date', + value: _draft.publicationDateRange, + onChanged: (value) => _updateDraft(_draft.copyWith(publicationDateRange: value)), + ), + _DateRangeField( + label: 'Date added', + value: _draft.dateAddedRange, + onChanged: (value) => _updateDraft(_draft.copyWith(dateAddedRange: value)), + ), + _DateRangeField( + label: 'Last read', + value: _draft.lastReadDateRange, + onChanged: (value) => _updateDraft(_draft.copyWith(lastReadDateRange: value)), + ), + ], + ), + ), + ), + _buildActionBar(context), + ], + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const BottomSheetHandle(), + const SizedBox(height: Spacing.lg), + Row( + children: [ + Text('Advanced filters', style: Theme.of(context).textTheme.headlineSmall), + const Spacer(), + IconButton(icon: const Icon(Icons.close), tooltip: 'Close', onPressed: () => Navigator.of(context).pop()), + ], + ), + ], + ), + ); + } + + Widget _buildActionBar(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final count = _matchingBookCount; + + return Container( + 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: Row( + children: [ + TextButton(onPressed: _resetDraft, child: const Text('Reset')), + const Spacer(), + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + const SizedBox(width: Spacing.sm), + FilledButton(onPressed: _applyDraft, child: Text('Show $count ${count == 1 ? 'book' : 'books'}')), + ], + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + final bool dividerBefore; + + const _SectionHeader({required this.title, this.dividerBefore = false}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (dividerBefore) ...[ + Divider(height: Spacing.lg, color: colorScheme.outlineVariant), + const SizedBox(height: Spacing.xs), + ], + Padding( + padding: const EdgeInsets.only(top: Spacing.sm, bottom: 12), + child: Semantics( + header: true, + child: Text( + title.toUpperCase(), + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ), + ), + ], + ); + } +} + +class _SearchableFacet extends StatefulWidget { + final String label; + final List> options; + final Set selectedValues; + final ValueChanged> onChanged; + + const _SearchableFacet({ + required this.label, + required this.options, + required this.selectedValues, + required this.onChanged, + }); + + @override + State<_SearchableFacet> createState() => _SearchableFacetState(); +} + +class _SearchableFacetState extends State<_SearchableFacet> { + late final TextEditingController _searchController; + bool _isExpanded = false; + String _query = ''; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController()..addListener(_synchronizeQuery); + } + + @override + void dispose() { + _searchController.removeListener(_synchronizeQuery); + _searchController.dispose(); + super.dispose(); + } + + void _synchronizeQuery() { + if (_query != _searchController.text) { + setState(() => _query = _searchController.text); + } + } + + void _toggleExpanded() { + setState(() => _isExpanded = !_isExpanded); + } + + void _updateSelection(LibraryFilterOption option, bool isSelected) { + final values = Set.of(widget.selectedValues); + if (isSelected) { + values.add(option.value); + } else { + values.remove(option.value); + } + widget.onChanged(values); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final inputDecorationTheme = theme.inputDecorationTheme; + final isEink = _isEinkTheme(theme); + final borderRadius = BorderRadius.circular(isEink ? AppRadius.none : AppRadius.lg); + final headerBorderRadius = _isExpanded + ? BorderRadius.vertical(top: Radius.circular(isEink ? AppRadius.none : AppRadius.lg)) + : borderRadius; + final selectionSummary = widget.selectedValues.isEmpty ? 'Any' : '${widget.selectedValues.length} selected'; + final normalizedQuery = _query.trim().toLowerCase(); + final visibleOptions = normalizedQuery.isEmpty + ? widget.options + : widget.options.where((option) => option.label.toLowerCase().contains(normalizedQuery)).toList(); + + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide( + color: colorScheme.outlineVariant, + width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, + ), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Semantics( + button: true, + expanded: _isExpanded, + label: '${widget.label}, $selectionSummary', + onTap: _toggleExpanded, + excludeSemantics: true, + child: InkWell( + borderRadius: headerBorderRadius, + onTap: _toggleExpanded, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 72), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: 14), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.label, style: theme.textTheme.titleSmall), + const SizedBox(height: 2), + Text( + selectionSummary, + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + Icon( + _isExpanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + if (_isExpanded) ...[ + Divider(height: 1, color: colorScheme.outlineVariant), + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, 12, Spacing.md, Spacing.md), + child: Column( + children: [ + SizedBox( + height: isEink ? ComponentSizes.inputHeightEink : TouchTargets.mobileRecommended, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search ${widget.label.toLowerCase()}...', + hintStyle: isEink + ? inputDecorationTheme.hintStyle + : TextStyle(color: colorScheme.onSurfaceVariant), + prefixIcon: Icon(Icons.search_rounded, color: colorScheme.onSurfaceVariant), + prefixIconConstraints: const BoxConstraints(minWidth: TouchTargets.mobileMin), + filled: true, + fillColor: isEink ? inputDecorationTheme.fillColor : colorScheme.surfaceContainerHighest, + isDense: true, + contentPadding: isEink + ? inputDecorationTheme.contentPadding + : const EdgeInsets.symmetric(vertical: 12, horizontal: Spacing.md), + border: isEink + ? inputDecorationTheme.border + : OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide.none, + ), + enabledBorder: isEink + ? inputDecorationTheme.enabledBorder + : OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide.none, + ), + focusedBorder: isEink + ? inputDecorationTheme.focusedBorder + : OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide(color: colorScheme.primary, width: BorderWidths.inputFocused), + ), + ), + ), + ), + const SizedBox(height: Spacing.xs), + if (visibleOptions.isEmpty) + SizedBox( + height: 48, + child: Center( + child: Text( + 'No matches', + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.separated( + shrinkWrap: true, + primary: false, + padding: EdgeInsets.zero, + itemCount: visibleOptions.length, + separatorBuilder: (context, index) => Divider(height: 1, color: colorScheme.outlineVariant), + itemBuilder: (context, index) { + final option = visibleOptions[index]; + final isSelected = widget.selectedValues.contains(option.value); + return _FacetOptionRow( + label: option.label, + isSelected: isSelected, + onChanged: (selected) => _updateSelection(option, selected), + ); + }, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } +} + +class _FacetOptionRow extends StatelessWidget { + final String label; + final bool isSelected; + final ValueChanged onChanged; + + const _FacetOptionRow({required this.label, required this.isSelected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + void toggleSelection() => onChanged(!isSelected); + + return Semantics( + container: true, + enabled: true, + checked: isSelected, + label: label, + onTap: toggleSelection, + excludeSemantics: true, + child: Material( + type: MaterialType.transparency, + child: InkWell( + excludeFromSemantics: true, + onTap: toggleSelection, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ExcludeFocus( + child: ExcludeSemantics( + child: IgnorePointer( + child: Checkbox(value: isSelected, onChanged: (_) => toggleSelection()), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ControlGroup extends StatelessWidget { + final String label; + final String? summary; + final Widget child; + + const _ControlGroup({required this.label, this.summary, required this.child}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(label, style: theme.textTheme.titleSmall), + if (summary != null) ...[ + const SizedBox(width: Spacing.sm), + Text(summary!, style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ], + ), + const SizedBox(height: Spacing.sm), + child, + ], + ), + ); + } +} + +Widget _selectionChip( + BuildContext context, { + required String label, + required bool isSelected, + required VoidCallback onSelected, +}) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isEink = _isEinkTheme(theme); + + return FilterChip( + label: Text(label), + selected: isSelected, + showCheckmark: true, + checkmarkColor: colorScheme.onSecondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : colorScheme.outlineVariant, + width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(isEink ? AppRadius.none : AppRadius.md)), + backgroundColor: Colors.transparent, + selectedColor: colorScheme.secondaryContainer, + labelStyle: theme.textTheme.labelLarge?.copyWith( + color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onSelected: (_) => onSelected(), + ); +} + +class _SmallFacet extends StatelessWidget { + final String label; + final bool showSummary; + final List> options; + final Set selectedValues; + final ValueChanged> onChanged; + + const _SmallFacet({ + required this.label, + this.showSummary = true, + required this.options, + required this.selectedValues, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _ControlGroup( + label: label, + summary: showSummary ? (selectedValues.isEmpty ? 'Any' : '${selectedValues.length} selected') : null, + child: Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: [ + for (final option in options) + Builder( + builder: (context) { + final isSelected = selectedValues.contains(option.value); + return _selectionChip( + context, + label: option.label, + isSelected: isSelected, + onSelected: () { + final values = Set.of(selectedValues); + if (!values.add(option.value)) { + values.remove(option.value); + } + onChanged(values); + }, + ); + }, + ), + ], + ), + ); + } +} + +class _FavoriteFilterField extends StatelessWidget { + final FavoriteFilter value; + final ValueChanged onChanged; + + const _FavoriteFilterField({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + Widget choiceChip(String label, FavoriteFilter filter) { + final isSelected = value == filter; + return _selectionChip(context, label: label, isSelected: isSelected, onSelected: () => onChanged(filter)); + } + + return _ControlGroup( + label: 'Favorite state', + child: Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: [ + choiceChip('Any', FavoriteFilter.any), + choiceChip('Favorites', FavoriteFilter.favorites), + choiceChip('Not favorites', FavoriteFilter.notFavorites), + ], + ), + ); + } +} + +class _ProgressFilterField extends StatefulWidget { + final LibraryProgressRange? value; + final ValueChanged onChanged; + + const _ProgressFilterField({required this.value, required this.onChanged}); + + @override + State<_ProgressFilterField> createState() => _ProgressFilterFieldState(); +} + +class _ProgressFilterFieldState extends State<_ProgressFilterField> { + RangeValues get _values { + final value = widget.value; + return value == null ? const RangeValues(0, 100) : RangeValues(value.start * 100, value.end * 100); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isEnabled = widget.value != null; + final values = _values; + final summary = isEnabled ? '${values.start.round()}% – ${values.end.round()}%' : 'Any'; + + void toggleEnabled() { + widget.onChanged(isEnabled ? null : const LibraryProgressRange(0, 1)); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + children: [ + Semantics( + container: true, + toggled: isEnabled, + label: 'Progress percentage, $summary', + onTap: toggleEnabled, + excludeSemantics: true, + child: Material( + type: MaterialType.transparency, + child: InkWell( + excludeFromSemantics: true, + onTap: toggleEnabled, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Progress percentage', style: theme.textTheme.titleSmall), + const SizedBox(height: 2), + Text( + summary, + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ExcludeFocus( + child: ExcludeSemantics( + child: IgnorePointer( + child: Switch(value: isEnabled, onChanged: (_) => toggleEnabled()), + ), + ), + ), + ], + ), + ), + ), + ), + ), + if (isEnabled) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: RangeSlider( + values: values, + min: 0, + max: 100, + padding: EdgeInsets.zero, + divisions: 20, + labels: RangeLabels('${values.start.round()}%', '${values.end.round()}%'), + onChanged: (range) { + widget.onChanged(LibraryProgressRange(range.start / 100, range.end / 100)); + }, + ), + ), + ], + ), + ); + } +} + +class _RatingFilterField extends StatelessWidget { + final Set ratings; + final bool includeUnrated; + final List availableRatings; + final bool showUnrated; + final void Function(Set ratings, bool includeUnrated) onChanged; + + const _RatingFilterField({ + required this.ratings, + required this.includeUnrated, + required this.availableRatings, + required this.showUnrated, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _ControlGroup( + label: 'Rating', + child: Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: [ + if (showUnrated) + _selectionChip( + context, + label: 'Unrated', + isSelected: includeUnrated, + onSelected: () => onChanged(ratings, !includeUnrated), + ), + for (final rating in availableRatings) + Builder( + builder: (context) { + final isSelected = ratings.contains(rating); + return _selectionChip( + context, + label: List.filled(rating, '★').join(), + isSelected: isSelected, + onSelected: () { + final values = Set.of(ratings); + if (!values.add(rating)) { + values.remove(rating); + } + onChanged(values, includeUnrated); + }, + ); + }, + ), + ], + ), + ); + } +} + +class _DateRangeField extends StatelessWidget { + final String label; + final LibraryDateRange? value; + final ValueChanged onChanged; + + const _DateRangeField({required this.label, required this.value, required this.onChanged}); + + Future _pickRange(BuildContext context) async { + final now = DateTime.now(); + final selectedRange = await showDateRangePicker( + context: context, + firstDate: DateTime(1000), + lastDate: DateTime(now.year + 10, 12, 31), + initialDateRange: value == null ? null : DateTimeRange(start: value!.start, end: value!.end), + ); + + if (selectedRange != null) { + onChanged(LibraryDateRange(selectedRange.start, selectedRange.end)); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isEink = _isEinkTheme(theme); + final localizations = MaterialLocalizations.of(context); + final value = this.value; + final summary = value == null + ? 'Any' + : '${localizations.formatCompactDate(value.start)} – ${localizations.formatCompactDate(value.end)}'; + final borderRadius = BorderRadius.circular(isEink ? AppRadius.none : AppRadius.sm); + final pickerBorderRadius = value == null + ? borderRadius + : BorderRadius.horizontal(left: Radius.circular(isEink ? AppRadius.none : AppRadius.sm)); + + void pickRange() { + _pickRange(context); + } + + return _ControlGroup( + label: label, + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide( + color: colorScheme.outlineVariant, + width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, + ), + ), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 54), + child: Row( + children: [ + Expanded( + child: Semantics( + button: true, + label: 'Select $label, $summary', + onTap: pickRange, + excludeSemantics: true, + child: InkWell( + borderRadius: pickerBorderRadius, + excludeFromSemantics: true, + onTap: pickRange, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 54), + child: Padding( + padding: const EdgeInsets.only(left: Spacing.md), + child: Row( + children: [ + Expanded(child: Text(summary)), + if (value == null) const Icon(Icons.chevron_right_rounded), + ], + ), + ), + ), + ), + ), + ), + if (value != null) + IconButton( + icon: const Icon(Icons.clear_rounded), + tooltip: 'Clear $label', + onPressed: () => onChanged(null), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/library/library_filter_chips.dart b/app/lib/widgets/library/library_filter_chips.dart index f700949..446f9bc 100644 --- a/app/lib/widgets/library/library_filter_chips.dart +++ b/app/lib/widgets/library/library_filter_chips.dart @@ -1,12 +1,269 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/library_filter_options.dart'; +import 'package:papyrus/models/library_filters.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/enums/library_sort_option.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; -import 'package:papyrus/widgets/shared/quick_filter_chips.dart'; +import 'package:papyrus/utils/book_language.dart'; import 'package:provider/provider.dart'; -/// Horizontal scrollable filter chips for library filtering. -/// Thin wrapper around [QuickFilterChips] that reads [LibraryProvider]. +class _ChipEntry { + final String id; + final bool isActive; + final int defaultOrder; + final Widget child; + + const _ChipEntry({required this.id, required this.isActive, required this.defaultOrder, required this.child}); +} + +class _SelectionOption { + final T value; + final String label; + final IconData? icon; + + const _SelectionOption({required this.value, required this.label, this.icon}); +} + +class _DropdownFilterChip extends StatelessWidget { + final String label; + final String semanticLabel; + final IconData icon; + final bool isSelected; + final String? tooltip; + final VoidCallback onPressed; + + const _DropdownFilterChip({ + required this.label, + required this.semanticLabel, + required this.icon, + required this.isSelected, + required this.onPressed, + this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foregroundColor = isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant; + + return Semantics( + button: true, + selected: isSelected, + label: '$semanticLabel: $label', + child: ActionChip( + tooltip: tooltip, + avatar: Icon(icon, size: 18, color: foregroundColor), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 2), + Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: foregroundColor), + ], + ), + labelStyle: TextStyle(color: foregroundColor, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal), + backgroundColor: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, + side: BorderSide(color: isSelected ? colorScheme.secondaryContainer : colorScheme.outlineVariant), + shape: const StadiumBorder(), + onPressed: onPressed, + ), + ); + } +} + +class _SingleSelectionSheet extends StatelessWidget { + final String title; + final List<_SelectionOption> options; + final T selectedValue; + + const _SingleSelectionSheet({required this.title, required this.options, required this.selectedValue}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SheetTitle(title: title), + const Divider(height: 1), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: options.length, + itemBuilder: (context, index) { + final option = options[index]; + final isSelected = option.value == selectedValue; + + return ListTile( + selected: isSelected, + leading: option.icon == null ? null : Icon(option.icon), + title: Text(option.label), + trailing: isSelected ? const Icon(Icons.check_rounded) : null, + onTap: () => Navigator.of(context).pop(option.value), + ); + }, + ), + ), + ], + ); + } +} + +class _MultiSelectionSheet extends StatefulWidget { + final String title; + final List<_SelectionOption> options; + final Set selectedValues; + final bool searchable; + + const _MultiSelectionSheet({ + required this.title, + required this.options, + required this.selectedValues, + required this.searchable, + }); + + @override + State<_MultiSelectionSheet> createState() => _MultiSelectionSheetState(); +} + +class _MultiSelectionSheetState extends State<_MultiSelectionSheet> { + late final Set _selectedValues = Set.of(widget.selectedValues); + String _searchQuery = ''; + + @override + Widget build(BuildContext context) { + final normalizedQuery = _searchQuery.trim().toLowerCase(); + final visibleOptions = normalizedQuery.isEmpty + ? widget.options + : widget.options.where((option) => option.label.toLowerCase().contains(normalizedQuery)).toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SheetTitle(title: widget.title), + if (widget.searchable) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: TextField( + decoration: InputDecoration( + hintText: 'Search ${widget.title.toLowerCase()}...', + prefixIcon: const Icon(Icons.search_rounded), + isDense: true, + ), + onChanged: (query) => setState(() => _searchQuery = query), + ), + ), + const Divider(height: 1), + Flexible( + child: visibleOptions.isEmpty + ? const Padding(padding: EdgeInsets.symmetric(horizontal: 24, vertical: 40), child: Text('No matches')) + : ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: visibleOptions.length, + itemBuilder: (context, index) { + final option = visibleOptions[index]; + final isSelected = _selectedValues.contains(option.value); + + return CheckboxListTile( + value: isSelected, + secondary: option.icon == null ? null : Icon(option.icon), + title: Text(option.label), + controlAffinity: ListTileControlAffinity.trailing, + onChanged: (_) { + setState(() { + if (isSelected) { + _selectedValues.remove(option.value); + } else { + _selectedValues.add(option.value); + } + }); + }, + ); + }, + ), + ), + const Divider(height: 1), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(Set.of(_selectedValues)), + child: const Text('Apply'), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _SheetTitle extends StatelessWidget { + final String title; + + const _SheetTitle({required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + ); + } +} + +Future _showSingleSelectionSheet( + BuildContext context, { + required String title, + required List<_SelectionOption> options, + required T selectedValue, +}) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + useRootNavigator: true, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => _SingleSelectionSheet(title: title, options: options, selectedValue: selectedValue), + ); +} + +Future?> _showMultiSelectionSheet( + BuildContext context, { + required String title, + required List<_SelectionOption> options, + required Set selectedValues, + bool searchable = false, +}) { + return showModalBottomSheet>( + context: context, + useSafeArea: true, + useRootNavigator: true, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => + _MultiSelectionSheet(title: title, options: options, selectedValues: selectedValues, searchable: searchable), + ); +} + class LibraryFilterChips extends StatelessWidget { final double? horizontalPadding; + final LibraryFilterOptions? filterOptions; final bool showDownloading; final bool isDownloadingSelected; final VoidCallback? onDownloadingTapped; @@ -15,60 +272,470 @@ class LibraryFilterChips extends StatelessWidget { const LibraryFilterChips({ super.key, this.horizontalPadding, + this.filterOptions, this.showDownloading = false, this.isDownloadingSelected = false, this.onDownloadingTapped, this.onLibraryFilterTapped, }); - static const _filters = [ - (type: LibraryFilterType.all, label: 'All', icon: Icons.apps), - (type: LibraryFilterType.reading, label: 'Reading', icon: Icons.auto_stories), - (type: LibraryFilterType.favorites, label: 'Favorites', icon: Icons.favorite), - (type: LibraryFilterType.finished, label: 'Finished', icon: Icons.check_circle), - (type: LibraryFilterType.unread, label: 'Unread', icon: Icons.book), + static final List<_SelectionOption> _sortOptions = [ + for (final option in LibrarySortOption.values) + _SelectionOption(value: option, label: option.label), + ]; + + static final List<_SelectionOption> _favoriteOptions = [ + const _SelectionOption(value: FavoriteFilter.any, label: 'Any'), + const _SelectionOption(value: FavoriteFilter.favorites, label: 'Favorites', icon: Icons.favorite_outline), + const _SelectionOption( + value: FavoriteFilter.notFavorites, + label: 'Not favorites', + icon: Icons.heart_broken_outlined, + ), + ]; + + static final List<_SelectionOption> _viewModeOptions = [ + for (final option in LibraryViewMode.values) _SelectionOption(value: option, label: option.label), ]; @override Widget build(BuildContext context) { - final libraryProvider = context.watch(); - final filters = _filters - .map( - (filter) => QuickFilterChipData( - label: filter.label, - icon: filter.icon, - isSelected: !isDownloadingSelected && libraryProvider.isFilterActive(filter.type), + final provider = context.watch(); + final dataStore = context.watch(); + final filterOptions = this.filterOptions ?? LibraryFilterOptions.fromDataStore(dataStore); + final statusOptions = [ + for (final option in filterOptions.readingStatuses) + _SelectionOption(value: option.value, label: option.label, icon: option.value.icon), + ]; + final effectiveStatusOptions = _withSelectedSelectionOptions( + statusOptions, + provider.selectedStatuses, + (status) => status.label, + ); + final authorOptions = _withIcon( + _withSelectedFilterOptions( + filterOptions.authors, + provider.selectedAuthors, + (value) => _authorLabel(dataStore.books, value), + ), + Icons.person_outline_rounded, + ); + final languageOptions = _withIcon( + _withSelectedFilterOptions( + filterOptions.languages, + provider.selectedLanguages, + (value) => _languageLabel(dataStore.books, value), + ), + Icons.language_rounded, + ); + final formatOptions = _withIcon( + _withSelectedFilterOptions( + filterOptions.formats, + provider.selectedFormats, + (value) => _formatLabel(dataStore.books, value), + ), + Icons.description_outlined, + ); + final topicOptions = _withIcon( + _withSelectedFilterOptions( + filterOptions.topics, + provider.selectedTopicIds, + (value) => _topicLabel(dataStore, value), + ), + Icons.label_outline_rounded, + ); + final shelfOptions = _withIcon( + _withSelectedFilterOptions( + filterOptions.shelves, + provider.selectedShelfIds, + (value) => _shelfLabel(dataStore, value), + ), + Icons.folder_outlined, + ); + final selectedSort = _sortOptions.firstWhere((option) => option.value == provider.sortOption); + final selectedFavorite = _favoriteOptions.firstWhere((option) => option.value == provider.favoriteFilter); + final selectedViewMode = _viewModeOptions.firstWhere((option) => option.value == provider.viewMode); + + final chips = <_ChipEntry>[ + _ChipEntry( + id: 'status', + defaultOrder: 0, + isActive: provider.selectedStatuses.isNotEmpty, + child: _DropdownFilterChip( + label: _multiSelectionLabel('Status', provider.selectedStatuses, effectiveStatusOptions), + semanticLabel: 'Reading status', + icon: Icons.auto_stories_outlined, + isSelected: provider.selectedStatuses.isNotEmpty, + tooltip: 'Filter by reading status', + onPressed: () => _selectMultiple( + context: context, + title: 'Reading status', + options: effectiveStatusOptions, + selectedValues: provider.selectedStatuses, + onSelected: provider.setStatusFilters, + ), + ), + ), + _ChipEntry( + id: 'sort', + defaultOrder: 1, + isActive: provider.sortOption != LibrarySortOption.dateAddedNewest, + child: _DropdownFilterChip( + label: selectedSort.label, + semanticLabel: 'Book sorting', + icon: Icons.sort_rounded, + isSelected: provider.sortOption != LibrarySortOption.dateAddedNewest, + tooltip: 'Sort books', + onPressed: () => _selectSingle( + context: context, + title: 'Sort books', + options: _sortOptions, + selectedValue: provider.sortOption, + onSelected: provider.setSortOption, + ), + ), + ), + _ChipEntry( + id: 'favorites', + defaultOrder: 2, + isActive: provider.favoriteFilter != FavoriteFilter.any, + child: _DropdownFilterChip( + label: provider.favoriteFilter == FavoriteFilter.any ? 'Favorites' : selectedFavorite.label, + semanticLabel: 'Favorite state', + icon: selectedFavorite.icon ?? Icons.favorite_outline, + isSelected: provider.favoriteFilter != FavoriteFilter.any, + tooltip: 'Filter by favorite state', + onPressed: () => _selectSingle( + context: context, + title: 'Favorite state', + options: _favoriteOptions, + selectedValue: provider.favoriteFilter, + onSelected: provider.setFavoriteFilter, + ), + ), + ), + _ChipEntry( + id: 'author', + defaultOrder: 3, + isActive: provider.selectedAuthors.isNotEmpty, + child: _DropdownFilterChip( + label: _multiSelectionLabel('Author', provider.selectedAuthors, authorOptions), + semanticLabel: 'Author', + icon: Icons.person_outline_rounded, + isSelected: provider.selectedAuthors.isNotEmpty, + tooltip: 'Filter by author', + onPressed: () => _selectMultiple( + context: context, + title: 'Authors', + options: authorOptions, + selectedValues: provider.selectedAuthors, + onSelected: provider.setAuthorFilters, + searchable: true, + ), + ), + ), + _ChipEntry( + id: 'language', + defaultOrder: 4, + isActive: provider.selectedLanguages.isNotEmpty, + child: _DropdownFilterChip( + label: _multiSelectionLabel('Language', provider.selectedLanguages, languageOptions), + semanticLabel: 'Language', + icon: Icons.language_rounded, + isSelected: provider.selectedLanguages.isNotEmpty, + tooltip: 'Filter by language', + onPressed: () => _selectMultiple( + context: context, + title: 'Languages', + options: languageOptions, + selectedValues: provider.selectedLanguages, + onSelected: provider.setLanguageFilters, + searchable: true, + ), + ), + ), + _ChipEntry( + id: 'format', + defaultOrder: 5, + isActive: provider.selectedFormats.isNotEmpty, + child: _DropdownFilterChip( + label: _multiSelectionLabel('Format', provider.selectedFormats, formatOptions), + semanticLabel: 'Format', + icon: Icons.description_outlined, + isSelected: provider.selectedFormats.isNotEmpty, + tooltip: 'Filter by format', + onPressed: () => _selectMultiple( + context: context, + title: 'Formats', + options: formatOptions, + selectedValues: provider.selectedFormats, + onSelected: provider.setFormatFilters, + ), + ), + ), + _ChipEntry( + id: 'topic', + defaultOrder: 6, + isActive: provider.selectedTopicIds.isNotEmpty, + child: _DropdownFilterChip( + label: _multiSelectionLabel('Topic', provider.selectedTopicIds, topicOptions), + semanticLabel: 'Topic', + icon: Icons.label_outline_rounded, + isSelected: provider.selectedTopicIds.isNotEmpty, + tooltip: 'Filter by topic', + onPressed: () => _selectMultiple( + context: context, + title: 'Topics', + options: topicOptions, + selectedValues: provider.selectedTopicIds, + onSelected: provider.setTopicFilters, + searchable: true, ), - ) - .toList(); + ), + ), + _ChipEntry( + id: 'shelf', + defaultOrder: 7, + isActive: provider.selectedShelfIds.isNotEmpty, + child: _DropdownFilterChip( + label: _multiSelectionLabel('Shelf', provider.selectedShelfIds, shelfOptions), + semanticLabel: 'Shelf', + icon: Icons.folder_outlined, + isSelected: provider.selectedShelfIds.isNotEmpty, + tooltip: 'Filter by shelf', + onPressed: () => _selectMultiple( + context: context, + title: 'Shelves', + options: shelfOptions, + selectedValues: provider.selectedShelfIds, + onSelected: provider.setShelfFilters, + searchable: true, + ), + ), + ), + _ChipEntry( + id: 'view-mode', + defaultOrder: 8, + isActive: provider.viewMode != LibraryViewMode.smallGrid, + child: _DropdownFilterChip( + label: selectedViewMode.label, + semanticLabel: 'View mode', + icon: Icons.grid_on, + isSelected: provider.viewMode != LibraryViewMode.smallGrid, + tooltip: 'Change view mode', + onPressed: () => _selectSingle( + context: context, + title: 'View mode', + options: _viewModeOptions, + selectedValue: provider.viewMode, + onSelected: provider.setViewMode, + ), + ), + ), + ]; + + final hasActiveSelections = + chips.any((chip) => chip.isActive) || provider.activeFilterCount > 0 || isDownloadingSelected; + + chips.sort((a, b) { + final activeComparison = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); + return activeComparison != 0 ? activeComparison : a.defaultOrder.compareTo(b.defaultOrder); + }); + + final orderKey = [...chips.map((chip) => chip.id), if (hasActiveSelections) 'clear-all'].join('-'); + + return SizedBox( + height: 48, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final offsetAnimation = Tween(begin: const Offset(0.03, 0), end: Offset.zero).animate(animation); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offsetAnimation, child: child), + ); + }, + layoutBuilder: (currentChild, previousChildren) { + return Stack(alignment: Alignment.centerLeft, children: [...previousChildren, ?currentChild]); + }, + child: ListView.separated( + key: ValueKey(orderKey), + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding ?? 16), + itemCount: chips.length + (hasActiveSelections ? 1 : 0), + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + if (index < chips.length) { + final chip = chips[index]; + return KeyedSubtree(key: ValueKey(chip.id), child: chip.child); + } + + return Align( + alignment: Alignment.center, + child: TextButton( + key: const ValueKey('clear-all'), + onPressed: () { + provider.resetQuickFilters(); + if (isDownloadingSelected) { + onDownloadingTapped?.call(); + } + onLibraryFilterTapped?.call(); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 40), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: const Text('Clear all'), + ), + ); + }, + ), + ), + ); + } + + Future _selectSingle({ + required BuildContext context, + required String title, + required List<_SelectionOption> options, + required T selectedValue, + required ValueChanged onSelected, + }) async { + final result = await _showSingleSelectionSheet( + context, + title: title, + options: options, + selectedValue: selectedValue, + ); - if (showDownloading) { - filters.add( - QuickFilterChipData(label: 'Downloading', icon: Icons.downloading_outlined, isSelected: isDownloadingSelected), - ); + if (result == null || result == selectedValue) { + return; } - return QuickFilterChips( - horizontalPadding: horizontalPadding, - filters: filters, - onFilterTapped: (index) { - if (index == _filters.length) { - libraryProvider.resetFilters(); - onDownloadingTapped?.call(); + onSelected(result); + onLibraryFilterTapped?.call(); + } - return; - } + Future _selectMultiple({ + required BuildContext context, + required String title, + required List<_SelectionOption> options, + required Set selectedValues, + required ValueChanged> onSelected, + bool searchable = false, + }) async { + final result = await _showMultiSelectionSheet( + context, + title: title, + options: options, + selectedValues: selectedValues, + searchable: searchable, + ); - final filter = _filters[index]; + if (result == null || setEquals(result, selectedValues)) { + return; + } + + onSelected(result); + onLibraryFilterTapped?.call(); + } + + static String _multiSelectionLabel(String category, Set selectedValues, List<_SelectionOption> options) { + if (selectedValues.isEmpty) { + return category; + } + if (selectedValues.length > 1) { + return '$category · ${selectedValues.length}'; + } + + final selectedValue = selectedValues.first; + for (final option in options) { + if (option.value == selectedValue) { + return option.label; + } + } + return category; + } + + static List<_SelectionOption> _withIcon(List> options, IconData icon) { + return [for (final option in options) _SelectionOption(value: option.value, label: option.label, icon: icon)]; + } + + static List<_SelectionOption> _withSelectedSelectionOptions( + List<_SelectionOption> options, + Set selectedValues, + String Function(T value) labelForValue, + ) { + return [ + ...options, + for (final value in selectedValues) + if (!options.any((option) => option.value == value)) + _SelectionOption(value: value, label: labelForValue(value)), + ]; + } + + static List> _withSelectedFilterOptions( + List> options, + Set selectedValues, + String Function(String value) labelForValue, + ) { + return [ + ...options, + for (final value in selectedValues) + if (!options.any((option) => option.value == value)) + LibraryFilterOption(value: value, label: labelForValue(value)), + ]; + } - if (filter.type == LibraryFilterType.all) { - libraryProvider.resetFilters(); - } else { - libraryProvider.toggleFilter(filter.type); + static String _authorLabel(Iterable books, String value) { + for (final book in books) { + for (final author in [book.author, ...book.coAuthors]) { + if (author.trim().toLowerCase() == value) { + return author.trim(); } + } + } + return 'Unknown author ($value)'; + } - onLibraryFilterTapped?.call(); - }, - ); + static String _formatLabel(Iterable books, String value) { + for (final book in books) { + if (book.formatLabel.toLowerCase() == value) { + return book.formatLabel; + } + } + return 'Unknown format ($value)'; + } + + static String _languageLabel(Iterable books, String value) { + for (final book in books) { + if (normalizeBookLanguage(book.language) == value) { + return bookLanguageLabel(book.language ?? value); + } + } + final label = bookLanguageLabel(value); + return label == value ? 'Unknown language ($value)' : label; + } + + static String _topicLabel(DataStore dataStore, String value) { + for (final topic in dataStore.tags) { + if (topic.id == value) { + return topic.name; + } + } + return 'Unknown topic ($value)'; + } + + static String _shelfLabel(DataStore dataStore, String value) { + for (final shelf in dataStore.shelves) { + if (shelf.id == value) { + return shelf.name; + } + } + return 'Unknown shelf ($value)'; } } diff --git a/app/lib/widgets/search/library_search_bar.dart b/app/lib/widgets/search/library_search_bar.dart index b59ee59..fb2a53d 100644 --- a/app/lib/widgets/search/library_search_bar.dart +++ b/app/lib/widgets/search/library_search_bar.dart @@ -1,23 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/utils/search_query_parser.dart'; -/// Search bar with query language support and filter button. -/// Used for both mobile and desktop library views. -/// Suggestions appear as an overlay (like IDE autocomplete) and support -/// keyboard navigation (arrow keys, Tab/Enter to select, Escape to dismiss). +/// Plain-text book search used by library views. class LibrarySearchBar extends StatefulWidget { - /// Callback when search query changes. final ValueChanged? onQueryChanged; - - /// Callback when filter button is tapped. final VoidCallback? onFilterTap; - - /// Current active filter count for badge. final int activeFilterCount; - - /// Initial search query. final String initialQuery; const LibrarySearchBar({ @@ -35,16 +23,11 @@ class LibrarySearchBar extends StatefulWidget { class _LibrarySearchBarState extends State { final TextEditingController _controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); - final LayerLink _layerLink = LayerLink(); - OverlayEntry? _overlayEntry; - List _suggestions = []; - int _selectedIndex = -1; @override void initState() { super.initState(); _controller.text = widget.initialQuery; - _focusNode.addListener(_onFocusChange); } @override @@ -57,309 +40,91 @@ class _LibrarySearchBarState extends State { @override void dispose() { - _removeOverlay(); _controller.dispose(); - _focusNode.removeListener(_onFocusChange); _focusNode.dispose(); super.dispose(); } - void _onFocusChange() { - if (!_focusNode.hasFocus) { - _removeOverlay(); - setState(() { - _suggestions = []; - _selectedIndex = -1; - }); - } - } - void _onQueryChanged(String value) { - _updateSuggestions(value); + setState(() {}); widget.onQueryChanged?.call(value); } - void _updateSuggestions(String text) { - final lastWord = text.split(' ').last.toLowerCase(); - - if (lastWord.isEmpty) { - _suggestions = []; - _selectedIndex = -1; - _removeOverlay(); - return; - } - - List newSuggestions = []; - - // Check if typing a field name - if (!lastWord.contains(':')) { - newSuggestions = SearchQueryParser.fieldSuggestions.where((s) => s.toLowerCase().startsWith(lastWord)).toList(); - } - // Check if typing status value - else if (lastWord.startsWith('status:')) { - final value = lastWord.substring(7); - newSuggestions = SearchQueryParser.statusSuggestions.where((s) => s.toLowerCase().contains(value)).toList(); - } - // Check if typing format value - else if (lastWord.startsWith('format:')) { - final value = lastWord.substring(7); - newSuggestions = SearchQueryParser.formatSuggestions.where((s) => s.toLowerCase().contains(value)).toList(); - } - - _suggestions = newSuggestions; - _selectedIndex = newSuggestions.isEmpty ? -1 : 0; - _showOrUpdateOverlay(); - } - - void _applySuggestion(String suggestion) { - final text = _controller.text; - final lastSpace = text.lastIndexOf(' '); - final prefix = lastSpace >= 0 ? text.substring(0, lastSpace + 1) : ''; - _controller.text = '$prefix$suggestion'; - _controller.selection = TextSelection.fromPosition(TextPosition(offset: _controller.text.length)); - _suggestions = []; - _selectedIndex = -1; - _removeOverlay(); - _onQueryChanged(_controller.text); - } - void _clearSearch() { _controller.clear(); + setState(() {}); widget.onQueryChanged?.call(''); - _suggestions = []; - _selectedIndex = -1; - _removeOverlay(); } - // --------------------------------------------------------------------------- - // Overlay management - // --------------------------------------------------------------------------- - - void _showOrUpdateOverlay() { - if (_suggestions.isEmpty || !_focusNode.hasFocus) { - _removeOverlay(); - return; - } - if (_overlayEntry != null) { - _overlayEntry!.markNeedsBuild(); - } else { - _overlayEntry = _createOverlayEntry(); - Overlay.of(context).insert(_overlayEntry!); - } - } - - void _removeOverlay() { - _overlayEntry?.remove(); - _overlayEntry?.dispose(); - _overlayEntry = null; - } - - OverlayEntry _createOverlayEntry() { - final renderBox = context.findRenderObject() as RenderBox; - final size = renderBox.size; - - return OverlayEntry( - builder: (context) { - final colorScheme = Theme.of(context).colorScheme; - - return Positioned( - width: size.width, - child: CompositedTransformFollower( - link: _layerLink, - showWhenUnlinked: false, - offset: Offset(0, size.height + 4), - child: Material( - elevation: AppElevation.level3, - borderRadius: BorderRadius.circular(AppRadius.md), - color: colorScheme.surfaceContainer, - surfaceTintColor: Colors.transparent, - child: TextFieldTapRegion( + Widget _buildFilterButton(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasActiveFilters = widget.activeFilterCount > 0; + final semanticLabel = hasActiveFilters + ? 'Advanced filters, ${widget.activeFilterCount} active' + : 'Advanced filters'; + + return Semantics( + button: true, + label: semanticLabel, + child: ExcludeSemantics( + child: Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + icon: Icon( + Icons.tune_rounded, + color: hasActiveFilters ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + tooltip: semanticLabel, + onPressed: widget.onFilterTap, + ), + if (hasActiveFilters) + Positioned( + right: 2, + top: 2, child: Container( - constraints: const BoxConstraints(maxHeight: 200), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.md), - child: ListView.builder( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - itemCount: _suggestions.length, - itemBuilder: (context, index) { - final suggestion = _suggestions[index]; - final isHighlighted = index == _selectedIndex; - return _SuggestionTile( - suggestion: suggestion, - isHighlighted: isHighlighted, - onTap: () => _applySuggestion(suggestion), - ); - }, + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text( + '${widget.activeFilterCount}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimary, + fontSize: 10, + fontWeight: FontWeight.bold, ), ), ), ), - ), - ), - ); - }, - ); - } - - // --------------------------------------------------------------------------- - // Keyboard navigation - // --------------------------------------------------------------------------- - - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (_suggestions.isEmpty) return KeyEventResult.ignored; - - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - - final key = event.logicalKey; - - if (key == LogicalKeyboardKey.arrowDown) { - _selectedIndex = (_selectedIndex + 1) % _suggestions.length; - _showOrUpdateOverlay(); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowUp) { - _selectedIndex = (_selectedIndex - 1 + _suggestions.length) % _suggestions.length; - _showOrUpdateOverlay(); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.tab || key == LogicalKeyboardKey.enter) { - if (_selectedIndex >= 0 && _selectedIndex < _suggestions.length) { - _applySuggestion(_suggestions[_selectedIndex]); - return KeyEventResult.handled; - } - } - if (key == LogicalKeyboardKey.escape) { - _suggestions = []; - _selectedIndex = -1; - _removeOverlay(); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - - // --------------------------------------------------------------------------- - // Build - // --------------------------------------------------------------------------- - - Widget _buildFilterButton(ColorScheme colorScheme) { - return Stack( - children: [ - IconButton( - icon: Icon( - Icons.tune, - color: widget.activeFilterCount > 0 ? colorScheme.primary : colorScheme.onSurfaceVariant, - ), - onPressed: widget.onFilterTap, - tooltip: 'Filters', - ), - if (widget.activeFilterCount > 0) - Positioned( - right: 4, - top: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), - constraints: const BoxConstraints(minWidth: 18, minHeight: 18), - child: Text( - '${widget.activeFilterCount}', - style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return CompositedTransformTarget( - link: _layerLink, - child: Focus( - onKeyEvent: _handleKeyEvent, - child: TextField( - controller: _controller, - focusNode: _focusNode, - decoration: InputDecoration( - hintText: 'Search books...', - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.text.isNotEmpty) - IconButton(icon: const Icon(Icons.clear), onPressed: _clearSearch, tooltip: 'Clear'), - _buildFilterButton(colorScheme), - ], - ), - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - ), - onChanged: _onQueryChanged, + ], ), ), ); } -} - -class _SuggestionTile extends StatelessWidget { - final String suggestion; - final bool isHighlighted; - final VoidCallback onTap; - - const _SuggestionTile({required this.suggestion, required this.isHighlighted, required this.onTap}); - - IconData _getIconForSuggestion(String suggestion) { - final lower = suggestion.toLowerCase(); - if (lower.startsWith('author')) return Icons.person_outline; - if (lower.startsWith('format')) return Icons.book_outlined; - if (lower.startsWith('shelf')) return Icons.folder_outlined; - if (lower.startsWith('topic')) return Icons.label_outline; - if (lower.startsWith('status')) return Icons.schedule; - if (lower.startsWith('progress')) return Icons.show_chart; - return Icons.search; - } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final platform = Theme.of(context).platform; - final isDesktopPlatform = - platform == TargetPlatform.macOS || platform == TargetPlatform.windows || platform == TargetPlatform.linux; - - return InkWell( - onTap: onTap, - child: Container( - color: isHighlighted ? colorScheme.primary.withValues(alpha: 0.12) : null, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - child: Row( - children: [ - Icon( - _getIconForSuggestion(suggestion), - color: isHighlighted ? colorScheme.primary : colorScheme.onSurfaceVariant, - size: IconSizes.small, - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - suggestion, - style: TextStyle(color: isHighlighted ? colorScheme.primary : colorScheme.onSurface), + return TextField( + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Search books...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _controller.text.isEmpty && widget.onFilterTap == null + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.text.isNotEmpty) + IconButton(icon: const Icon(Icons.clear), onPressed: _clearSearch, tooltip: 'Clear'), + if (widget.onFilterTap != null) _buildFilterButton(context), + ], ), - ), - if (isHighlighted && isDesktopPlatform) - Text('Tab', style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), ), + onChanged: _onQueryChanged, ); } } diff --git a/app/lib/widgets/shared/quick_filter_chips.dart b/app/lib/widgets/shared/quick_filter_chips.dart index fa1a0b8..7c9a2b8 100644 --- a/app/lib/widgets/shared/quick_filter_chips.dart +++ b/app/lib/widgets/shared/quick_filter_chips.dart @@ -1,65 +1,65 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/themes/design_tokens.dart'; +// import 'package:flutter/material.dart'; +// import 'package:papyrus/themes/design_tokens.dart'; -/// Data for a single quick filter chip. -class QuickFilterChipData { - final String label; - final IconData icon; - final bool isSelected; +// /// Data for a single quick filter chip. +// class QuickFilterChipData { +// final String label; +// final IconData icon; +// final bool isSelected; - const QuickFilterChipData({required this.label, required this.icon, required this.isSelected}); -} +// const QuickFilterChipData({required this.label, required this.icon, required this.isSelected}); +// } -/// Horizontal scrollable filter chips, provider-agnostic. -class QuickFilterChips extends StatelessWidget { - final List filters; - final ValueChanged onFilterTapped; - final double? horizontalPadding; +// /// Horizontal scrollable filter chips, provider-agnostic. +// class QuickFilterChips extends StatelessWidget { +// final List filters; +// final ValueChanged onFilterTapped; +// final double? horizontalPadding; - const QuickFilterChips({super.key, required this.filters, required this.onFilterTapped, this.horizontalPadding}); +// const QuickFilterChips({super.key, required this.filters, required this.onFilterTapped, this.horizontalPadding}); - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; +// @override +// Widget build(BuildContext context) { +// final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.sm), - child: SizedBox( - height: 48, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: EdgeInsets.symmetric(horizontal: horizontalPadding ?? Spacing.md), - itemCount: filters.length, - separatorBuilder: (context, index) => const SizedBox(width: Spacing.sm), - itemBuilder: (context, index) { - final filter = filters[index]; +// return Padding( +// padding: const EdgeInsets.symmetric(vertical: Spacing.sm), +// child: SizedBox( +// height: 48, +// child: ListView.separated( +// scrollDirection: Axis.horizontal, +// padding: EdgeInsets.symmetric(horizontal: horizontalPadding ?? Spacing.md), +// itemCount: filters.length, +// separatorBuilder: (context, index) => const SizedBox(width: Spacing.sm), +// itemBuilder: (context, index) { +// final filter = filters[index]; - return FilterChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - filter.icon, - size: IconSizes.small, - color: filter.isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: Spacing.xs), - Text( - filter.label, - style: TextStyle( - color: filter.isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - selected: filter.isSelected, - showCheckmark: false, - side: BorderSide(color: colorScheme.outline), - onSelected: (_) => onFilterTapped(index), - ); - }, - ), - ), - ); - } -} +// return FilterChip( +// label: Row( +// mainAxisSize: MainAxisSize.min, +// children: [ +// Icon( +// filter.icon, +// size: IconSizes.small, +// color: filter.isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, +// ), +// const SizedBox(width: Spacing.xs), +// Text( +// filter.label, +// style: TextStyle( +// color: filter.isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, +// ), +// ), +// ], +// ), +// selected: filter.isSelected, +// showCheckmark: false, +// side: BorderSide(color: colorScheme.outline), +// onSelected: (_) => onFilterTapped(index), +// ); +// }, +// ), +// ), +// ); +// } +// } diff --git a/app/lib/widgets/shelves/add_shelf_sheet.dart b/app/lib/widgets/shelves/add_shelf_sheet.dart index 97107f7..5e92866 100644 --- a/app/lib/widgets/shelves/add_shelf_sheet.dart +++ b/app/lib/widgets/shelves/add_shelf_sheet.dart @@ -23,6 +23,7 @@ class AddShelfSheet extends StatefulWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (context) => AddShelfSheet(shelf: shelf, onSave: onSave), ); diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index 42d6ed7..51d4750 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -30,6 +30,7 @@ class MoveToShelfSheet extends StatefulWidget { static Future show(BuildContext context, {required Book book, void Function(List shelfIds)? onSave}) { return showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (context) => MoveToShelfSheet(book: book, onSave: onSave), diff --git a/app/lib/widgets/shelves/shelves_filter_chips.dart b/app/lib/widgets/shelves/shelves_filter_chips.dart new file mode 100644 index 0000000..bfa0875 --- /dev/null +++ b/app/lib/widgets/shelves/shelves_filter_chips.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/shelves_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:provider/provider.dart'; + +typedef _ShelfSortSelection = ({ShelfSortOption option, bool ascending}); + +bool _isEinkTheme(ThemeData theme) { + final border = theme.inputDecorationTheme.border; + return border is OutlineInputBorder && + border.borderRadius == BorderRadius.zero && + border.borderSide.width >= BorderWidths.einkDefault; +} + +class _ChipEntry { + final String id; + final bool isActive; + final int defaultOrder; + final Widget child; + + const _ChipEntry({required this.id, required this.isActive, required this.defaultOrder, required this.child}); +} + +class _SelectionOption { + final T value; + final String label; + final IconData? icon; + + const _SelectionOption({required this.value, required this.label, this.icon}); +} + +class _DropdownFilterChip extends StatelessWidget { + final String label; + final String semanticLabel; + final IconData icon; + final bool isSelected; + final String? tooltip; + final VoidCallback onPressed; + + const _DropdownFilterChip({ + required this.label, + required this.semanticLabel, + required this.icon, + required this.isSelected, + required this.onPressed, + this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isEink = _isEinkTheme(theme); + final foregroundColor = isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant; + + return Semantics( + button: true, + selected: isSelected, + label: '$semanticLabel: $label', + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: isEink ? TouchTargets.einkMin : 0), + child: ActionChip( + tooltip: tooltip, + avatar: Icon(icon, size: 18, color: foregroundColor), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 2), + Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: foregroundColor), + ], + ), + labelStyle: TextStyle(color: foregroundColor, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal), + backgroundColor: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, + side: BorderSide( + color: isSelected ? Colors.transparent : colorScheme.outlineVariant, + width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(isEink ? AppRadius.none : AppRadius.full)), + visualDensity: isEink ? VisualDensity.standard : VisualDensity.compact, + onPressed: onPressed, + ), + ), + ); + } +} + +class _SingleSelectionSheet extends StatelessWidget { + final String title; + final List<_SelectionOption> options; + final T selectedValue; + + const _SingleSelectionSheet({required this.title, required this.options, required this.selectedValue}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + ), + const Divider(height: 1), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: options.length, + itemBuilder: (context, index) { + final option = options[index]; + final isSelected = option.value == selectedValue; + + return Semantics( + selected: isSelected, + child: ListTile( + selected: isSelected, + leading: option.icon == null ? null : Icon(option.icon), + title: Text(option.label), + trailing: isSelected ? const Icon(Icons.check_rounded) : null, + onTap: () => Navigator.of(context).pop(option.value), + ), + ); + }, + ), + ), + ], + ); + } +} + +Future _showSingleSelectionSheet( + BuildContext context, { + required String title, + required List<_SelectionOption> options, + required T selectedValue, +}) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + useRootNavigator: true, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => _SingleSelectionSheet(title: title, options: options, selectedValue: selectedValue), + ); +} + +class ShelvesFilterChips extends StatelessWidget { + final double? horizontalPadding; + + const ShelvesFilterChips({super.key, this.horizontalPadding}); + + static const List<_SelectionOption> _contentsOptions = [ + _SelectionOption(value: ShelfContentsFilter.all, label: 'Any'), + _SelectionOption(value: ShelfContentsFilter.withBooks, label: 'With books', icon: Icons.auto_stories_outlined), + _SelectionOption(value: ShelfContentsFilter.empty, label: 'Empty', icon: Icons.inventory_2_outlined), + ]; + + static const List<_SelectionOption> _typeOptions = [ + _SelectionOption(value: ShelfTypeFilter.all, label: 'Any'), + _SelectionOption(value: ShelfTypeFilter.regular, label: 'Regular', icon: Icons.shelves), + _SelectionOption(value: ShelfTypeFilter.smart, label: 'Smart', icon: Icons.auto_awesome_outlined), + ]; + + static const List<_SelectionOption<_ShelfSortSelection>> _sortOptions = [ + _SelectionOption(value: (option: ShelfSortOption.name, ascending: true), label: 'Name (A-Z)'), + _SelectionOption(value: (option: ShelfSortOption.name, ascending: false), label: 'Name (Z-A)'), + _SelectionOption(value: (option: ShelfSortOption.bookCount, ascending: true), label: 'Book count (Ascending)'), + _SelectionOption(value: (option: ShelfSortOption.bookCount, ascending: false), label: 'Book count (Descending)'), + _SelectionOption(value: (option: ShelfSortOption.dateCreated, ascending: false), label: 'Date created (newest)'), + _SelectionOption(value: (option: ShelfSortOption.dateCreated, ascending: true), label: 'Date created (oldest)'), + _SelectionOption(value: (option: ShelfSortOption.dateModified, ascending: false), label: 'Date modified (newest)'), + _SelectionOption(value: (option: ShelfSortOption.dateModified, ascending: true), label: 'Date modified (oldest)'), + ]; + + static const List<_SelectionOption> _viewOptions = [ + _SelectionOption(value: ShelvesViewMode.smallGrid, label: 'Small grid'), + _SelectionOption(value: ShelvesViewMode.largeGrid, label: 'Large grid'), + _SelectionOption(value: ShelvesViewMode.list, label: 'List'), + ]; + + @override + Widget build(BuildContext context) { + final isEink = _isEinkTheme(Theme.of(context)); + final provider = context.watch(); + final selectedContents = _optionFor(_contentsOptions, provider.contentsFilter); + final selectedType = _optionFor(_typeOptions, provider.typeFilter); + final selectedSortValue = (option: provider.shelfSortOption, ascending: provider.shelfSortAscending); + final selectedSort = _optionFor(_sortOptions, selectedSortValue); + final selectedView = _optionFor(_viewOptions, provider.viewMode); + + final chips = <_ChipEntry>[ + _ChipEntry( + id: 'contents', + defaultOrder: 0, + isActive: provider.contentsFilter != ShelfContentsFilter.all, + child: _DropdownFilterChip( + label: provider.contentsFilter == ShelfContentsFilter.all ? 'Contents' : selectedContents.label, + semanticLabel: 'Shelf contents', + icon: Icons.auto_stories_outlined, + isSelected: provider.contentsFilter != ShelfContentsFilter.all, + tooltip: 'Filter by shelf contents', + onPressed: () => _selectSingle( + context: context, + title: 'Shelf contents', + options: _contentsOptions, + selectedValue: provider.contentsFilter, + onSelected: provider.setContentsFilter, + ), + ), + ), + _ChipEntry( + id: 'sort', + defaultOrder: 1, + isActive: provider.shelfSortOption != ShelfSortOption.name || !provider.shelfSortAscending, + child: _DropdownFilterChip( + label: selectedSort.label, + semanticLabel: 'Shelf sorting', + icon: Icons.sort_rounded, + isSelected: provider.shelfSortOption != ShelfSortOption.name || !provider.shelfSortAscending, + tooltip: 'Sort shelves', + onPressed: () => _selectSingle<_ShelfSortSelection>( + context: context, + title: 'Sort shelves', + options: _sortOptions, + selectedValue: selectedSortValue, + onSelected: (selection) => provider.setShelfSortOption(selection.option, ascending: selection.ascending), + ), + ), + ), + _ChipEntry( + id: 'type', + defaultOrder: 2, + isActive: provider.typeFilter != ShelfTypeFilter.all, + child: _DropdownFilterChip( + label: provider.typeFilter == ShelfTypeFilter.all ? 'Type' : selectedType.label, + semanticLabel: 'Shelf type', + icon: Icons.shelves, + isSelected: provider.typeFilter != ShelfTypeFilter.all, + tooltip: 'Filter by shelf type', + onPressed: () => _selectSingle( + context: context, + title: 'Shelf type', + options: _typeOptions, + selectedValue: provider.typeFilter, + onSelected: provider.setTypeFilter, + ), + ), + ), + _ChipEntry( + id: 'view', + defaultOrder: 3, + isActive: provider.viewMode != ShelvesViewMode.smallGrid, + child: _DropdownFilterChip( + label: selectedView.label, + semanticLabel: 'View mode', + icon: Icons.grid_on, + isSelected: provider.viewMode != ShelvesViewMode.smallGrid, + tooltip: 'Change view mode', + onPressed: () => _selectSingle( + context: context, + title: 'View mode', + options: _viewOptions, + selectedValue: provider.viewMode, + onSelected: provider.setViewMode, + ), + ), + ), + ]; + + chips.sort((a, b) { + final activeComparison = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); + return activeComparison != 0 ? activeComparison : a.defaultOrder.compareTo(b.defaultOrder); + }); + + final orderKey = [...chips.map((chip) => chip.id), if (provider.hasActiveShelfControls) 'clear-all'].join('-'); + + return SizedBox( + height: isEink ? TouchTargets.einkMin : TouchTargets.mobileRecommended, + child: AnimatedSwitcher( + duration: isEink ? AnimationDurations.eink : AnimationDurations.standard, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final offsetAnimation = Tween(begin: const Offset(0.03, 0), end: Offset.zero).animate(animation); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offsetAnimation, child: child), + ); + }, + layoutBuilder: (currentChild, previousChildren) { + return Stack(alignment: Alignment.centerLeft, children: [...previousChildren, ?currentChild]); + }, + child: ListView.separated( + key: ValueKey(orderKey), + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding ?? 16), + itemCount: chips.length + (provider.hasActiveShelfControls ? 1 : 0), + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + if (index < chips.length) { + final chip = chips[index]; + return KeyedSubtree(key: ValueKey(chip.id), child: chip.child); + } + + return Align( + alignment: Alignment.center, + child: TextButton( + key: const ValueKey('clear-all'), + onPressed: provider.clearShelfControls, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: Size(0, isEink ? TouchTargets.einkMin : TouchTargets.mobileMin), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: isEink ? VisualDensity.standard : VisualDensity.compact, + shape: isEink ? const RoundedRectangleBorder(borderRadius: BorderRadius.zero) : null, + ), + child: const Text('Clear all'), + ), + ); + }, + ), + ), + ); + } + + Future _selectSingle({ + required BuildContext context, + required String title, + required List<_SelectionOption> options, + required T selectedValue, + required ValueChanged onSelected, + }) async { + final result = await _showSingleSelectionSheet( + context, + title: title, + options: options, + selectedValue: selectedValue, + ); + + if (result == null || result == selectedValue) { + return; + } + + onSelected(result); + } + + static _SelectionOption _optionFor(List<_SelectionOption> options, T selectedValue) { + return options.firstWhere((option) => option.value == selectedValue); + } +} diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index 7f6e442..db5b203 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -30,6 +30,7 @@ class ManageTopicsSheet extends StatefulWidget { static Future show(BuildContext context, {required Book book, void Function(List tagIds)? onSave}) { return showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (context) => ManageTopicsSheet(book: book, onSave: onSave), diff --git a/app/pubspec.lock b/app/pubspec.lock index 6f40134..b53c413 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -483,10 +483,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -499,10 +499,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -969,10 +969,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/app/test/helpers/test_helpers.dart b/app/test/helpers/test_helpers.dart index a3a5557..a4f0a03 100644 --- a/app/test/helpers/test_helpers.dart +++ b/app/test/helpers/test_helpers.dart @@ -6,6 +6,7 @@ import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; @@ -70,7 +71,7 @@ List createTestBooks() { id: 'book-1', title: 'The Hobbit', author: 'J.R.R. Tolkien', - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPosition: 0.5, isFavorite: true, fileFormat: BookFormat.epub, @@ -80,7 +81,7 @@ List createTestBooks() { id: 'book-2', title: 'Dune', author: 'Frank Herbert', - readingStatus: ReadingStatus.completed, + readingStatus: LibraryReadingStatus.completed, currentPosition: 1.0, isFavorite: false, fileFormat: BookFormat.pdf, @@ -90,7 +91,7 @@ List createTestBooks() { id: 'book-3', title: '1984', author: 'George Orwell', - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, isFavorite: false, fileFormat: BookFormat.mobi, @@ -100,7 +101,7 @@ List createTestBooks() { id: 'book-4', title: 'Foundation', author: 'Isaac Asimov', - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0, isFavorite: true, isPhysical: true, @@ -110,7 +111,7 @@ List createTestBooks() { id: 'book-5', title: 'Neuromancer', author: 'William Gibson', - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPosition: 0.25, isFavorite: false, fileFormat: BookFormat.epub, @@ -153,7 +154,7 @@ Book buildTestBook({ String title = 'Test Book', String author = 'Test Author', List coAuthors = const [], - ReadingStatus readingStatus = ReadingStatus.notStarted, + LibraryReadingStatus readingStatus = LibraryReadingStatus.unread, double currentPosition = 0.0, int? currentPage, int? pageCount, diff --git a/app/test/models/book_test.dart b/app/test/models/book_test.dart index 9418405..76a1ad2 100644 --- a/app/test/models/book_test.dart +++ b/app/test/models/book_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import '../helpers/test_helpers.dart'; @@ -24,17 +25,6 @@ void main() { expect(buildTestBook(currentPosition: 0.0).progressLabel, '0%'); }); - test('isReading returns true only for inProgress status', () { - expect(buildTestBook(readingStatus: ReadingStatus.inProgress).isReading, true); - expect(buildTestBook(readingStatus: ReadingStatus.completed).isReading, false); - expect(buildTestBook(readingStatus: ReadingStatus.notStarted).isReading, false); - }); - - test('isFinished returns true only for completed status', () { - expect(buildTestBook(readingStatus: ReadingStatus.completed).isFinished, true); - expect(buildTestBook(readingStatus: ReadingStatus.inProgress).isFinished, false); - }); - test('hasProgress returns true when currentPosition > 0', () { expect(buildTestBook(currentPosition: 0.01).hasProgress, true); expect(buildTestBook(currentPosition: 0.0).hasProgress, false); @@ -136,7 +126,7 @@ void main() { fileSize: 1024, fileHash: 'abc123', isPhysical: false, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPage: 150, currentPosition: 0.5, currentCfi: 'epubcfi(/6/14)', @@ -259,7 +249,7 @@ void main() { expect(book.isPhysical, true); expect(book.physicalLocation, 'Shelf A'); expect(book.lentTo, 'Alice'); - expect(book.readingStatus, ReadingStatus.completed); + expect(book.readingStatus, LibraryReadingStatus.completed); expect(book.currentPage, 300); expect(book.currentPosition, 1.0); expect(book.currentCfi, 'cfi'); @@ -277,7 +267,7 @@ void main() { expect(book.coAuthors, isEmpty); expect(book.isPhysical, false); - expect(book.readingStatus, ReadingStatus.notStarted); + expect(book.readingStatus, LibraryReadingStatus.unread); expect(book.currentPosition, 0.0); expect(book.isFavorite, false); expect(book.fileFormat, isNull); @@ -293,7 +283,7 @@ void main() { author: 'Author', coAuthors: ['Co1', 'Co2'], fileFormat: BookFormat.mobi, - readingStatus: ReadingStatus.paused, + readingStatus: LibraryReadingStatus.paused, currentPosition: 0.42, currentPage: 126, isFavorite: true, @@ -325,19 +315,11 @@ void main() { group('ReadingStatus', () { test('label returns display text', () { - expect(ReadingStatus.notStarted.label, 'Not started'); - expect(ReadingStatus.inProgress.label, 'Reading'); - expect(ReadingStatus.completed.label, 'Completed'); - expect(ReadingStatus.paused.label, 'Paused'); - expect(ReadingStatus.abandoned.label, 'Abandoned'); - }); - - test('shortLabel returns abbreviated text', () { - expect(ReadingStatus.notStarted.shortLabel, 'Not started'); - expect(ReadingStatus.inProgress.shortLabel, 'Reading'); - expect(ReadingStatus.completed.shortLabel, 'Finished'); - expect(ReadingStatus.paused.shortLabel, 'Paused'); - expect(ReadingStatus.abandoned.shortLabel, 'DNF'); + expect(LibraryReadingStatus.unread.label, 'Not started'); + expect(LibraryReadingStatus.inProgress.label, 'Reading'); + expect(LibraryReadingStatus.completed.label, 'Completed'); + expect(LibraryReadingStatus.paused.label, 'Paused'); + expect(LibraryReadingStatus.abandoned.label, 'Abandoned'); }); }); diff --git a/app/test/models/search_filter_test.dart b/app/test/models/search_filter_test.dart index 5f8ab8b..8be1303 100644 --- a/app/test/models/search_filter_test.dart +++ b/app/test/models/search_filter_test.dart @@ -6,6 +6,7 @@ import 'package:papyrus/models/book_tag_relation.dart'; import 'package:papyrus/models/search_filter.dart'; import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; void main() { final now = DateTime.now(); @@ -16,7 +17,7 @@ void main() { String author = 'J.R.R. Tolkien', bool isPhysical = false, BookFormat? fileFormat = BookFormat.epub, - ReadingStatus readingStatus = ReadingStatus.notStarted, + LibraryReadingStatus readingStatus = LibraryReadingStatus.unread, double currentPosition = 0.0, }) { return Book( @@ -95,18 +96,18 @@ void main() { group('SearchField.status', () { test('matches reading status', () { final filter = SearchFilter(field: SearchField.status, operator: SearchOperator.equals, value: 'reading'); - expect(filter.matches(makeBook(readingStatus: ReadingStatus.inProgress)), true); - expect(filter.matches(makeBook(readingStatus: ReadingStatus.notStarted)), false); + expect(filter.matches(makeBook(readingStatus: LibraryReadingStatus.inProgress)), true); + expect(filter.matches(makeBook(readingStatus: LibraryReadingStatus.unread)), false); }); test('matches finished status', () { final filter = SearchFilter(field: SearchField.status, operator: SearchOperator.equals, value: 'finished'); - expect(filter.matches(makeBook(readingStatus: ReadingStatus.completed)), true); + expect(filter.matches(makeBook(readingStatus: LibraryReadingStatus.completed)), true); }); test('matches unread status', () { final filter = SearchFilter(field: SearchField.status, operator: SearchOperator.equals, value: 'unread'); - expect(filter.matches(makeBook(readingStatus: ReadingStatus.notStarted, currentPosition: 0.0)), true); + expect(filter.matches(makeBook(readingStatus: LibraryReadingStatus.unread, currentPosition: 0.0)), true); }); }); diff --git a/app/test/pages/library_page_test.dart b/app/test/pages/library_page_test.dart index c52ddbb..89ee2db 100644 --- a/app/test/pages/library_page_test.dart +++ b/app/test/pages/library_page_test.dart @@ -6,13 +6,17 @@ import 'package:papyrus/acquisition/acquisition_models.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/models/library_filters.dart'; import 'package:papyrus/pages/library_page.dart'; import 'package:papyrus/providers/acquisition_downloads_provider.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_list_item.dart'; import 'package:papyrus/widgets/library/book_card.dart'; +import 'package:papyrus/widgets/library/book_grid.dart'; import 'package:papyrus/widgets/library/book_list_item.dart'; import 'package:papyrus/widgets/library/library_filter_chips.dart'; import 'package:papyrus/widgets/library/online_books_header.dart'; @@ -20,8 +24,6 @@ import 'package:papyrus/widgets/library/online_results_view.dart'; import 'package:papyrus/widgets/library/selection_header.dart'; import 'package:papyrus/widgets/search/library_search_bar.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; -import 'package:papyrus/widgets/shared/quick_filter_chips.dart'; -import 'package:papyrus/widgets/shared/view_mode_toggle.dart'; import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; @@ -60,6 +62,10 @@ void main() { ); } + List renderedGridBooks(WidgetTester tester) { + return tester.widget(find.byType(BookGrid)).books; + } + // ======================================================================== // Mobile layout tests // ======================================================================== @@ -180,7 +186,7 @@ void main() { await downloadsProvider.refreshConfiguration(); libraryProvider ..setSearchQuery('Dune Messiah') - ..addFilter(LibraryFilterType.favorites) + ..setFavoriteFilter(FavoriteFilter.favorites) ..setViewMode(LibraryViewMode.list); await tester.pumpWidget(buildPage(screenSize: const Size(400, 1000), downloadsProvider: downloadsProvider)); @@ -199,7 +205,7 @@ void main() { expect(find.byType(OnlineBooksHeader), findsNothing); expect(libraryProvider.searchQuery, 'Dune Messiah'); - expect(libraryProvider.isFilterActive(LibraryFilterType.favorites), isTrue); + expect(libraryProvider.favoriteFilter, FavoriteFilter.favorites); expect(libraryProvider.viewMode, LibraryViewMode.list); expect(gateway.searchQueries, hasLength(1)); @@ -783,59 +789,6 @@ void main() { downloadsProvider.dispose(); }); - testWidgets('Downloading is exclusive and toggles back to All', (tester) async { - final store = createTestDataStore( - books: [ - Book(id: 'linked-book', title: 'Linked book', author: 'Author', addedAt: DateTime(2026)), - Book(id: 'ordinary-book', title: 'Ordinary book', author: 'Author', addedAt: DateTime(2026)), - ], - ); - final downloadsProvider = AcquisitionDownloadsProvider( - gateway: _LibraryAcquisitionGateway( - jobs: [ - _libraryJob( - AcquisitionJobStatus.downloading, - id: 'linked-job', - bookId: 'linked-book', - title: 'Linked book', - ), - ], - ), - pollingInterval: Duration.zero, - ); - await downloadsProvider.refreshConfiguration(); - await downloadsProvider.refreshJobs(); - - await tester.pumpWidget( - buildPage(screenSize: const Size(1200, 1000), store: store, downloadsProvider: downloadsProvider), - ); - await tester.pumpAndSettle(); - - var chips = tester.widget(find.byType(QuickFilterChips)); - final downloadingIndex = chips.filters.indexWhere((filter) => filter.label == 'Downloading'); - - expect(downloadingIndex, isNonNegative); - chips.onFilterTapped(downloadingIndex); - await tester.pumpAndSettle(); - - chips = tester.widget(find.byType(QuickFilterChips)); - - expect(chips.filters.singleWhere((filter) => filter.label == 'Downloading').isSelected, isTrue); - expect(chips.filters.singleWhere((filter) => filter.label == 'All').isSelected, isFalse); - expect(find.text('Ordinary book'), findsNothing); - - chips.onFilterTapped(downloadingIndex); - await tester.pumpAndSettle(); - - chips = tester.widget(find.byType(QuickFilterChips)); - - expect(chips.filters.singleWhere((filter) => filter.label == 'Downloading').isSelected, isFalse); - expect(chips.filters.singleWhere((filter) => filter.label == 'All').isSelected, isTrue); - expect(find.text('Ordinary book'), findsWidgets); - - downloadsProvider.dispose(); - }); - testWidgets('list view integrates linked and orphan jobs without duplication', (tester) async { libraryProvider.setViewMode(LibraryViewMode.list); final store = createTestDataStore( @@ -1029,7 +982,7 @@ void main() { await tester.pump(); expect(downloadsProvider.selectedJobIds, {'linked-job'}); - libraryProvider.addFilter(LibraryFilterType.finished); + libraryProvider.setStatusFilters({LibraryReadingStatus.completed}); await tester.pump(); await tester.pump(); @@ -1433,21 +1386,6 @@ void main() { expect(find.byIcon(Icons.add), findsOneWidget); }); - testWidgets('displays book count', (tester) async { - await tester.pumpWidget(buildPage()); - await tester.pumpAndSettle(); - - expect(find.text('5 books'), findsOneWidget); - }); - - testWidgets('displays singular "book" when only 1 book', (tester) async { - final singleBookStore = createTestDataStore(books: [createTestBooks().first]); - await tester.pumpWidget(buildPage(store: singleBookStore)); - await tester.pumpAndSettle(); - - expect(find.text('1 book'), findsOneWidget); - }); - testWidgets('renders grid view by default', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); @@ -1455,15 +1393,6 @@ void main() { expect(find.byType(BookCard), findsWidgets); }); - testWidgets('renders view mode toggle', (tester) async { - await tester.pumpWidget(buildPage()); - await tester.pumpAndSettle(); - - expect(find.byType(ViewModeToggle), findsOneWidget); - expect(find.byIcon(Icons.grid_view), findsOneWidget); - expect(find.byIcon(Icons.view_list), findsOneWidget); - }); - testWidgets('switches to list view when list mode is selected', (tester) async { libraryProvider.setViewMode(LibraryViewMode.list); await tester.pumpWidget(buildPage()); @@ -1553,13 +1482,6 @@ void main() { expect(find.byIcon(Icons.menu), findsNothing); }); - testWidgets('renders view toggle buttons', (tester) async { - await tester.pumpWidget(buildPage(screenSize: desktopSize)); - await tester.pumpAndSettle(); - - expect(find.byType(ViewModeToggle), findsOneWidget); - }); - testWidgets('shows grid view by default on desktop', (tester) async { await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); @@ -1626,55 +1548,51 @@ void main() { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.text('5 books'), findsOneWidget); + expect(renderedGridBooks(tester), hasLength(5)); }); testWidgets('filters to reading books', (tester) async { - libraryProvider.addFilter(LibraryFilterType.reading); + libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - // 2 books are reading: The Hobbit and Neuromancer - expect(find.text('2 books'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), unorderedEquals(['The Hobbit', 'Neuromancer'])); }); testWidgets('filters to favorite books', (tester) async { - libraryProvider.addFilter(LibraryFilterType.favorites); + libraryProvider.setFavoriteFilter(FavoriteFilter.favorites); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - // 2 books are favorites: The Hobbit and Foundation - expect(find.text('2 books'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), unorderedEquals(['The Hobbit', 'Foundation'])); }); testWidgets('filters to finished books', (tester) async { - libraryProvider.addFilter(LibraryFilterType.finished); + libraryProvider.setStatusFilters({LibraryReadingStatus.completed}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - // 1 book is finished: Dune - expect(find.text('1 book'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), ['Dune']); }); testWidgets('filters to unread books', (tester) async { - libraryProvider.addFilter(LibraryFilterType.unread); + libraryProvider.setStatusFilters({LibraryReadingStatus.unread}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - // 2 books are unread: 1984 and Foundation - expect(find.text('2 books'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), unorderedEquals(['1984', 'Foundation'])); }); testWidgets('shows empty state when no books match filter', (tester) async { // Add a filter and clear all books - libraryProvider.addFilter(LibraryFilterType.reading); + libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); final storeWithNoReadingBooks = createTestDataStore( books: [ Book( id: 'book-1', title: 'Test Book', author: 'Test Author', - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, addedAt: DateTime.now(), ), ], @@ -1682,7 +1600,7 @@ void main() { await tester.pumpWidget(buildPage(store: storeWithNoReadingBooks)); await tester.pumpAndSettle(); - expect(find.text('0 books'), findsOneWidget); + expect(find.byType(BookGrid), findsNothing); expect(find.byType(EmptyState), findsOneWidget); }); @@ -1691,37 +1609,37 @@ void main() { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.text('1 book'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), ['The Hobbit']); }); testWidgets('search filter works with author field', (tester) async { - libraryProvider.setSearchQuery('author:Tolkien'); + libraryProvider.setSearchQuery('Tolkien'); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.text('1 book'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), ['The Hobbit']); }); testWidgets('filters by shelf', (tester) async { // Add a book-shelf relation dataStore.addBookToShelf('book-1', 'shelf-1'); - libraryProvider.selectShelf('Fiction'); + libraryProvider.setShelfFilters({'shelf-1'}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.text('1 book'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.id), ['book-1']); }); testWidgets('filters by topic', (tester) async { // Add a book-tag relation dataStore.addTagToBook('book-1', 'tag-1'); - libraryProvider.selectTopic('Fantasy'); + libraryProvider.setTopicFilters({'tag-1'}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.text('1 book'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.id), ['book-1']); }); }); @@ -1744,43 +1662,6 @@ void main() { expect(find.byType(BookListItem), findsWidgets); expect(find.byType(BookCard), findsNothing); }); - - testWidgets('tapping grid segment on mobile selects grid view', (tester) async { - libraryProvider.setViewMode(LibraryViewMode.list); - await tester.pumpWidget(buildPage()); - await tester.pumpAndSettle(); - - // Tap the grid view segment - await tester.tap(find.byIcon(Icons.grid_view)); - await tester.pumpAndSettle(); - - expect(libraryProvider.viewMode, LibraryViewMode.grid); - }); - - testWidgets('tapping list segment on mobile selects list view', (tester) async { - await tester.pumpWidget(buildPage()); - await tester.pumpAndSettle(); - - // Tap the list view segment - await tester.tap(find.byIcon(Icons.view_list)); - await tester.pumpAndSettle(); - - expect(libraryProvider.viewMode, LibraryViewMode.list); - }); - - testWidgets('tapping toggle on desktop switches view mode', (tester) async { - const desktopSize = Size(1200, 800); - await tester.pumpWidget(buildPage(screenSize: desktopSize)); - await tester.pumpAndSettle(); - - // Tap the list view toggle - expect(find.byType(ViewModeToggle), findsOneWidget); - - await tester.tap(find.byIcon(Icons.view_list)); - await tester.pumpAndSettle(); - - expect(libraryProvider.viewMode, LibraryViewMode.list); - }); }); // ======================================================================== @@ -1870,13 +1751,12 @@ void main() { group('combined filters', () { testWidgets('reading + favorites shows intersection', (tester) async { - libraryProvider.addFilter(LibraryFilterType.reading); - libraryProvider.addFilter(LibraryFilterType.favorites); + libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); + libraryProvider.setFavoriteFilter(FavoriteFilter.favorites); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - // Only The Hobbit is both reading and favorite - expect(find.text('1 book'), findsOneWidget); + expect(renderedGridBooks(tester).map((book) => book.title), ['The Hobbit']); }); testWidgets('empty search query shows all books', (tester) async { @@ -1884,7 +1764,7 @@ void main() { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.text('5 books'), findsOneWidget); + expect(renderedGridBooks(tester), hasLength(5)); }); }); }); diff --git a/app/test/powersync/powersync_book_mapper_test.dart b/app/test/powersync/powersync_book_mapper_test.dart index 153f077..f08f4db 100644 --- a/app/test/powersync/powersync_book_mapper_test.dart +++ b/app/test/powersync/powersync_book_mapper_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/powersync/powersync_book_mapper.dart'; void main() { @@ -20,7 +21,7 @@ void main() { fileHash: 'hash', isPhysical: true, physicalLocation: 'Shelf', - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPosition: 0.4, isFavorite: true, addedAt: DateTime.parse('2026-05-09T12:00:00Z'), @@ -60,7 +61,7 @@ void main() { expect(book.title, 'Synced Book'); expect(book.coAuthors, ['Co Author']); - expect(book.readingStatus, ReadingStatus.inProgress); + expect(book.readingStatus, LibraryReadingStatus.inProgress); expect(book.currentPosition, 0.5); expect(book.isFavorite, isTrue); expect(book.fileFormat, BookFormat.epub); diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index 50cc721..dccff20 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -6,6 +6,7 @@ import 'package:papyrus/data/repositories/book_repository.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/book_details_provider.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import '../helpers/test_helpers.dart'; @@ -24,7 +25,7 @@ void main() { title: 'Test Book', author: 'Author', currentPosition: 0.5, - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, isFavorite: false, pageCount: 300, ), @@ -465,7 +466,7 @@ void main() { provider.updatePageProgress(300, 1.0); - expect(provider.book!.readingStatus, ReadingStatus.completed); + expect(provider.book!.readingStatus, LibraryReadingStatus.completed); }); test('sets status to inProgress when position > 0', () async { @@ -474,7 +475,7 @@ void main() { provider.updatePageProgress(10, 0.1); - expect(provider.book!.readingStatus, ReadingStatus.inProgress); + expect(provider.book!.readingStatus, LibraryReadingStatus.inProgress); }); test('clamps position to 0.0-1.0', () async { diff --git a/app/test/providers/library_provider_test.dart b/app/test/providers/library_provider_test.dart index 2b2c1f2..a5c9b61 100644 --- a/app/test/providers/library_provider_test.dart +++ b/app/test/providers/library_provider_test.dart @@ -1,6 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/library_filters.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/enums/library_sort_option.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; +import '../helpers/test_helpers.dart'; + void main() { group('LibraryProvider', () { late LibraryProvider provider; @@ -9,240 +15,157 @@ void main() { provider = LibraryProvider(); }); - group('initial state', () { - test('should have grid view mode by default', () { - expect(provider.viewMode, LibraryViewMode.grid); - expect(provider.isGridView, true); - expect(provider.isListView, false); - }); - - test('should have "all" filter active by default', () { - expect(provider.activeFilters, {LibraryFilterType.all}); - expect(provider.isFilterActive(LibraryFilterType.all), true); - }); - - test('should have empty search query by default', () { - expect(provider.searchQuery, ''); - expect(provider.hasActiveAdvancedFilters, false); - }); + tearDown(() { + provider.dispose(); + }); - test('should have no shelf or topic selected by default', () { - expect(provider.selectedShelf, null); - expect(provider.selectedTopic, null); + group('initial state', () { + test('uses the default view, sort, search, and filters', () { + expect(provider.viewMode, LibraryViewMode.smallGrid); + expect(provider.sortOption, LibrarySortOption.dateAddedNewest); + expect(provider.searchQuery, isEmpty); + expect(provider.filters.isEmpty, isTrue); + expect(provider.activeFilterCount, 0); }); }); group('view mode', () { - test('should toggle between grid and list view', () { - expect(provider.isGridView, true); - - provider.toggleViewMode(); - expect(provider.isListView, true); - expect(provider.isGridView, false); - - provider.toggleViewMode(); - expect(provider.isGridView, true); - expect(provider.isListView, false); - }); + test('sets each view mode explicitly', () { + provider.setViewMode(LibraryViewMode.largeGrid); + expect(provider.viewMode, LibraryViewMode.largeGrid); - test('should set view mode directly', () { provider.setViewMode(LibraryViewMode.list); expect(provider.viewMode, LibraryViewMode.list); - provider.setViewMode(LibraryViewMode.grid); - expect(provider.viewMode, LibraryViewMode.grid); + provider.setViewMode(LibraryViewMode.smallGrid); + expect(provider.viewMode, LibraryViewMode.smallGrid); }); - }); - - group('quick filters', () { - test('should add filter and remove "all"', () { - provider.addFilter(LibraryFilterType.reading); - - expect(provider.isFilterActive(LibraryFilterType.reading), true); - expect(provider.isFilterActive(LibraryFilterType.all), false); - }); - - test('should allow multiple filters', () { - provider.addFilter(LibraryFilterType.reading); - provider.addFilter(LibraryFilterType.favorites); - - expect(provider.isFilterActive(LibraryFilterType.reading), true); - expect(provider.isFilterActive(LibraryFilterType.favorites), true); - expect(provider.activeFilters.length, 2); - }); - - test('should remove filter and default to "all" when empty', () { - provider.addFilter(LibraryFilterType.reading); - provider.removeFilter(LibraryFilterType.reading); - - expect(provider.isFilterActive(LibraryFilterType.reading), false); - expect(provider.isFilterActive(LibraryFilterType.all), true); - }); - - test('should toggle filter on and off', () { - expect(provider.isFilterActive(LibraryFilterType.reading), false); - provider.toggleFilter(LibraryFilterType.reading); - expect(provider.isFilterActive(LibraryFilterType.reading), true); + test('does not notify when the view mode is unchanged', () { + var notifications = 0; + provider.addListener(() => notifications++); - provider.toggleFilter(LibraryFilterType.reading); - expect(provider.isFilterActive(LibraryFilterType.reading), false); - }); - - test('should not duplicate filters when added multiple times', () { - provider.addFilter(LibraryFilterType.reading); - provider.addFilter(LibraryFilterType.reading); - provider.addFilter(LibraryFilterType.reading); + provider.setViewMode(LibraryViewMode.smallGrid); - // Set uses unique values - expect(provider.activeFilters.where((f) => f == LibraryFilterType.reading).length, 1); + expect(notifications, 0); }); }); - group('search query', () { - test('should set search query', () { - provider.setSearchQuery('test query'); - expect(provider.searchQuery, 'test query'); - }); - - test('should clear search query', () { - provider.setSearchQuery('test query'); - provider.clearSearch(); - expect(provider.searchQuery, ''); - }); - - test('should detect advanced filters in query', () { - provider.setSearchQuery('simple search'); - expect(provider.hasActiveAdvancedFilters, false); + group('structured filters', () { + test('category setters update the shared filter model', () { + provider.setStatusFilters({LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); + provider.setFavoriteFilter(FavoriteFilter.favorites); + provider.setAuthorFilters({' J.R.R. Tolkien '}); + provider.setLanguageFilters({'EN'}); + provider.setFormatFilters({' EPUB '}); + provider.setShelfFilters({'shelf-1'}); + provider.setTopicFilters({'topic-1'}); - provider.setSearchQuery('author:tolkien'); - expect(provider.hasActiveAdvancedFilters, true); + expect(provider.selectedStatuses, {LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); + expect(provider.favoriteFilter, FavoriteFilter.favorites); + expect(provider.selectedAuthors, {'j.r.r. tolkien'}); + expect(provider.selectedLanguages, {'en'}); + expect(provider.selectedFormats, {'epub'}); + expect(provider.selectedShelfIds, {'shelf-1'}); + expect(provider.selectedTopicIds, {'topic-1'}); + expect(provider.activeFilterCount, 7); }); - test('should clear shelf and topic filters when clearing search', () { - // Simulate applying filters via the filter dialog: - // shelf filter sets selectedShelf + shelves filter type + query - provider.selectShelf('Fiction'); - provider.setSearchQuery('shelf:"Fiction"'); + test('applyFilters replaces the entire filter draft with one notification', () { + var notifications = 0; + provider.addListener(() => notifications++); + final filters = LibraryFilters( + authors: {'frank herbert'}, + statuses: {LibraryReadingStatus.completed}, + ratings: {5}, + ); - // Simulate pressing the "X" clear button on the search bar - provider.clearSearch(); + provider.applyFilters(filters); - expect(provider.searchQuery, ''); - expect(provider.selectedShelf, isNull); - expect(provider.isFilterActive(LibraryFilterType.shelves), false); - expect(provider.isFilterActive(LibraryFilterType.all), true); + expect(provider.filters, filters); + expect(provider.activeFilterCount, 3); + expect(notifications, 1); }); - test('should clear topic filter when clearing search', () { - provider.selectTopic('Science'); - provider.setSearchQuery('topic:"Science"'); + test('clearFilters clears structured filters without clearing search', () { + provider.setSearchQuery('dune'); + provider.setStatusFilters({LibraryReadingStatus.completed}); + provider.setFavoriteFilter(FavoriteFilter.notFavorites); - provider.clearSearch(); + provider.clearFilters(); - expect(provider.searchQuery, ''); - expect(provider.selectedTopic, isNull); - expect(provider.isFilterActive(LibraryFilterType.topics), false); - expect(provider.isFilterActive(LibraryFilterType.all), true); + expect(provider.filters.isEmpty, isTrue); + expect(provider.searchQuery, 'dune'); }); - test('should clear all filter state when clearing search', () { - // Set up shelf/topic filters then add a quick filter chip on top - provider.selectShelf('Fiction'); - provider.selectTopic('Science'); - provider.addFilter(LibraryFilterType.reading); - provider.setSearchQuery('author:tolkien shelf:"Fiction" topic:"Science"'); + test('resetQuickFilters restores filters, sort, and view defaults', () { + provider.setStatusFilters({LibraryReadingStatus.inProgress}); + provider.setSortOption(LibrarySortOption.titleAZ); + provider.setViewMode(LibraryViewMode.list); - provider.clearSearch(); + provider.resetQuickFilters(); - expect(provider.searchQuery, ''); - expect(provider.selectedShelf, isNull); - expect(provider.selectedTopic, isNull); - expect(provider.isFilterActive(LibraryFilterType.shelves), false); - expect(provider.isFilterActive(LibraryFilterType.topics), false); - // Reading filter was set via chip, not search — should be preserved - expect(provider.isFilterActive(LibraryFilterType.reading), true); + expect(provider.filters.isEmpty, isTrue); + expect(provider.sortOption, LibrarySortOption.dateAddedNewest); + expect(provider.viewMode, LibraryViewMode.smallGrid); }); }); - group('shelf and topic selection', () { - test('should select shelf and set shelves filter', () { - provider.selectShelf('Fiction'); - - expect(provider.selectedShelf, 'Fiction'); - expect(provider.isFilterActive(LibraryFilterType.shelves), true); - }); + group('search query', () { + test('sets and clears plain text search independently', () { + provider.setStatusFilters({LibraryReadingStatus.inProgress}); + provider.setSearchQuery('tolkien'); - test('should clear shelf when set to null', () { - provider.selectShelf('Fiction'); - provider.selectShelf(null); + provider.clearSearch(); - expect(provider.selectedShelf, null); + expect(provider.searchQuery, isEmpty); + expect(provider.selectedStatuses, {LibraryReadingStatus.inProgress}); }); - test('should select topic and set topics filter', () { - provider.selectTopic('Science'); - - expect(provider.selectedTopic, 'Science'); - expect(provider.isFilterActive(LibraryFilterType.topics), true); - }); + test('does not notify when the search query is unchanged', () { + provider.setSearchQuery('test'); + var notifications = 0; + provider.addListener(() => notifications++); - test('should clear topic when set to null', () { - provider.selectTopic('Science'); - provider.selectTopic(null); + provider.setSearchQuery('test'); - expect(provider.selectedTopic, null); + expect(notifications, 0); }); }); - group('reset filters', () { - test('should reset all filters to default state', () { - // Set up various filters - provider.addFilter(LibraryFilterType.reading); - provider.addFilter(LibraryFilterType.favorites); - provider.setSearchQuery('author:tolkien'); - provider.selectShelf('Fiction'); - provider.selectTopic('Science'); - - // Reset - provider.resetFilters(); - - // Verify all reset - expect(provider.activeFilters, {LibraryFilterType.all}); - expect(provider.searchQuery, ''); - expect(provider.selectedShelf, null); - expect(provider.selectedTopic, null); - }); - }); + group('filterBooks', () { + test('combines text and structured categories with AND logic', () { + final books = createTestBooks(); + final dataStore = createTestDataStore(books: books); + provider.setSearchQuery('tolkien'); + provider.setStatusFilters({LibraryReadingStatus.inProgress}); + provider.setFavoriteFilter(FavoriteFilter.favorites); - group('notifyListeners', () { - test('should notify on view mode change', () { - var notified = false; - provider.addListener(() => notified = true); + final filtered = provider.filterBooks(books, dataStore: dataStore); - provider.toggleViewMode(); - expect(notified, true); + expect(filtered.map((book) => book.id), ['book-1']); }); - test('should notify on filter change', () { - var notifyCount = 0; - provider.addListener(() => notifyCount++); + test('uses OR logic within the reading-status category', () { + final books = createTestBooks(); + final dataStore = createTestDataStore(books: books); + provider.setStatusFilters({LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); - provider.addFilter(LibraryFilterType.reading); - provider.removeFilter(LibraryFilterType.reading); - provider.toggleFilter(LibraryFilterType.favorites); + final filtered = provider.filterBooks(books, dataStore: dataStore); - expect(notifyCount, 3); + expect(filtered.map((book) => book.id), ['book-1', 'book-2', 'book-5']); }); - test('should not notify when setting same search query', () { - provider.setSearchQuery('test'); + test('can preview a draft without replacing applied filters', () { + final books = createTestBooks(); + final dataStore = createTestDataStore(books: books); + provider.setStatusFilters({LibraryReadingStatus.unread}); + final draft = LibraryFilters(favoriteFilter: FavoriteFilter.favorites); - var notified = false; - provider.addListener(() => notified = true); + final preview = provider.filterBooks(books, dataStore: dataStore, filters: draft); - provider.setSearchQuery('test'); // Same value - expect(notified, false); + expect(preview.map((book) => book.id), ['book-1', 'book-4']); + expect(provider.selectedStatuses, {LibraryReadingStatus.unread}); }); }); }); diff --git a/app/test/reader/reader_book_adapter_test.dart b/app/test/reader/reader_book_adapter_test.dart index bffd987..cb4d688 100644 --- a/app/test/reader/reader_book_adapter_test.dart +++ b/app/test/reader/reader_book_adapter_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/reader/reader_book_adapter.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; @@ -76,7 +77,7 @@ void main() { expect(updated.customMetadata?[ReaderBookAdapter.locatorMetadataKey], locator.toJson()); expect(updated.currentPage, 5); expect(updated.currentPosition, 0.35); - expect(updated.readingStatus, ReadingStatus.inProgress); + expect(updated.readingStatus, LibraryReadingStatus.inProgress); expect(updated.lastReadAt, now); expect(updated.startedAt, now); }); diff --git a/app/test/widgets/book_details/book_action_buttons_test.dart b/app/test/widgets/book_details/book_action_buttons_test.dart index b2692b2..152e74f 100644 --- a/app/test/widgets/book_details/book_action_buttons_test.dart +++ b/app/test/widgets/book_details/book_action_buttons_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/widgets/book_details/book_action_buttons.dart'; import '../../helpers/test_helpers.dart'; @@ -19,7 +20,11 @@ void main() { child: BookActionButtons( book: book ?? - buildTestBook(fileFormat: BookFormat.epub, currentPosition: 0.5, readingStatus: ReadingStatus.inProgress), + buildTestBook( + fileFormat: BookFormat.epub, + currentPosition: 0.5, + readingStatus: LibraryReadingStatus.inProgress, + ), onContinueReading: onContinueReading, onUpdateProgress: onUpdateProgress, onToggleFavorite: onToggleFavorite, diff --git a/app/test/widgets/library/acquisition_placeholder_card_test.dart b/app/test/widgets/library/acquisition_placeholder_card_test.dart index 08358e0..e83c32e 100644 --- a/app/test/widgets/library/acquisition_placeholder_card_test.dart +++ b/app/test/widgets/library/acquisition_placeholder_card_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; @@ -271,7 +272,7 @@ Widget _buildCardPair({required ThemeData theme}) { id: 'book-1', title: 'A Synchronized Book', author: 'An Author', - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0, isFavorite: false, fileFormat: BookFormat.epub, diff --git a/app/test/widgets/library/acquisition_placeholder_list_item_test.dart b/app/test/widgets/library/acquisition_placeholder_list_item_test.dart index 3e8e069..a0b035f 100644 --- a/app/test/widgets/library/acquisition_placeholder_list_item_test.dart +++ b/app/test/widgets/library/acquisition_placeholder_list_item_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_list_item.dart'; @@ -148,7 +149,7 @@ void main() { id: 'book-1', title: 'A Book', author: 'An Author', - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0, isFavorite: false, fileFormat: BookFormat.epub, diff --git a/app/test/widgets/library/book_card_test.dart b/app/test/widgets/library/book_card_test.dart index 1df7c93..776dc5a 100644 --- a/app/test/widgets/library/book_card_test.dart +++ b/app/test/widgets/library/book_card_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/book_card.dart'; @@ -19,7 +20,7 @@ void main() { id: 'book-1', title: 'The Hobbit', author: 'J.R.R. Tolkien', - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPosition: 0.5, isFavorite: false, fileFormat: BookFormat.epub, diff --git a/app/test/widgets/library/book_grid_test.dart b/app/test/widgets/library/book_grid_test.dart index 910f0bf..0771359 100644 --- a/app/test/widgets/library/book_grid_test.dart +++ b/app/test/widgets/library/book_grid_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; @@ -368,6 +370,7 @@ Widget _buildGrid({ child: Scaffold( body: BookGrid( books: books, + libraryViewMode: LibraryViewMode.smallGrid, onBookTap: onBookTap, acquisitionJobsByBookId: acquisitionJobsByBookId, placeholderJobs: placeholderJobs, @@ -392,7 +395,7 @@ Book _book({required String id, required String title}) { id: id, title: title, author: 'Author', - readingStatus: ReadingStatus.notStarted, + readingStatus: LibraryReadingStatus.unread, currentPosition: 0, isFavorite: false, fileFormat: BookFormat.epub, diff --git a/app/test/widgets/library/book_list_item_test.dart b/app/test/widgets/library/book_list_item_test.dart index ba3a88b..72b8f76 100644 --- a/app/test/widgets/library/book_list_item_test.dart +++ b/app/test/widgets/library/book_list_item_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/book_list_item.dart'; @@ -16,7 +17,7 @@ void main() { id: 'book-1', title: 'The Hobbit', author: 'J.R.R. Tolkien', - readingStatus: ReadingStatus.inProgress, + readingStatus: LibraryReadingStatus.inProgress, currentPosition: 0.5, isFavorite: false, fileFormat: BookFormat.epub, @@ -238,7 +239,7 @@ void main() { }); testWidgets('displays 100% progress for finished book', (tester) async { - final finishedBook = testBook.copyWith(readingStatus: ReadingStatus.completed, currentPosition: 1.0); + final finishedBook = testBook.copyWith(readingStatus: LibraryReadingStatus.completed, currentPosition: 1.0); await tester.pumpWidget(buildListItem(book: finishedBook)); expect(find.text('100%'), findsOneWidget); }); diff --git a/app/test/widgets/library/library_filter_chips_test.dart b/app/test/widgets/library/library_filter_chips_test.dart index a561694..ff9f811 100644 --- a/app/test/widgets/library/library_filter_chips_test.dart +++ b/app/test/widgets/library/library_filter_chips_test.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/library_filters.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/providers/enums/library_sort_option.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/widgets/library/library_filter_chips.dart'; -import 'package:papyrus/widgets/shared/quick_filter_chips.dart'; import '../../helpers/test_helpers.dart'; @@ -14,195 +17,97 @@ void main() { libraryProvider = LibraryProvider(); }); - Widget buildChips({ - LibraryProvider? provider, - bool showDownloading = false, - bool isDownloadingSelected = false, - VoidCallback? onDownloadingTapped, - VoidCallback? onLibraryFilterTapped, - }) { + tearDown(() { + libraryProvider.dispose(); + }); + + Widget buildChips() { return createTestApp( - libraryProvider: provider ?? libraryProvider, - screenSize: const Size(1000, 800), - child: LibraryFilterChips( - showDownloading: showDownloading, - isDownloadingSelected: isDownloadingSelected, - onDownloadingTapped: onDownloadingTapped, - onLibraryFilterTapped: onLibraryFilterTapped, - ), + libraryProvider: libraryProvider, + screenSize: const Size(1800, 800), + child: const LibraryFilterChips(), ); } - testWidgets('displays all five filter chips', (tester) async { + Future pumpChips(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1800, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(buildChips()); + } + + testWidgets('renders the current filter, sort, and view categories', (tester) async { + await pumpChips(tester); - expect(find.text('All'), findsOneWidget); - expect(find.text('Reading'), findsOneWidget); + expect(find.text('Status'), findsOneWidget); + expect(find.text(LibrarySortOption.dateAddedNewest.label), findsOneWidget); expect(find.text('Favorites'), findsOneWidget); - expect(find.text('Finished'), findsOneWidget); - expect(find.text('Unread'), findsOneWidget); + expect(find.text('Author'), findsOneWidget); + expect(find.text('Language'), findsOneWidget); + expect(find.text('Format'), findsOneWidget); + expect(find.text('Topic'), findsOneWidget); + expect(find.text('Shelf'), findsOneWidget); + expect(find.text(LibraryViewMode.smallGrid.label), findsOneWidget); }); - testWidgets('"All" chip is selected by default', (tester) async { - await tester.pumpWidget(buildChips()); + testWidgets('uses a horizontal list with fixed height', (tester) async { + await pumpChips(tester); + + final listView = tester.widget(find.byType(ListView)); + final container = tester.widget( + find.ancestor(of: find.byType(AnimatedSwitcher), matching: find.byType(SizedBox)).first, + ); - final allChip = tester.widget(find.ancestor(of: find.text('All'), matching: find.byType(FilterChip))); - expect(allChip.selected, true); + expect(listView.scrollDirection, Axis.horizontal); + expect(container.height, 48); }); - testWidgets('tapping "Reading" chip toggles reading filter', (tester) async { - await tester.pumpWidget(buildChips()); + testWidgets('shows active multi-select categories first with a count label', (tester) async { + libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); - await tester.tap(find.text('Reading')); - await tester.pumpAndSettle(); + await pumpChips(tester); - expect(libraryProvider.isFilterActive(LibraryFilterType.reading), true); - expect(libraryProvider.isFilterActive(LibraryFilterType.all), false); + expect(find.text('Status · 2'), findsOneWidget); + expect(libraryProvider.activeFilterCount, 1); }); - testWidgets('tapping "Favorites" chip toggles favorites filter', (tester) async { - await tester.pumpWidget(buildChips()); + testWidgets('favorite selection sheet updates the shared filter model', (tester) async { + await pumpChips(tester); await tester.tap(find.text('Favorites')); await tester.pumpAndSettle(); + expect(find.text('Favorite state'), findsOneWidget); - expect(libraryProvider.isFilterActive(LibraryFilterType.favorites), true); - }); - - testWidgets('tapping "Finished" chip toggles finished filter', (tester) async { - await tester.pumpWidget(buildChips()); - - await tester.tap(find.text('Finished')); + await tester.tap(find.text('Favorites').last); await tester.pumpAndSettle(); - expect(libraryProvider.isFilterActive(LibraryFilterType.finished), true); + expect(libraryProvider.favoriteFilter, FavoriteFilter.favorites); }); - testWidgets('tapping "Unread" chip toggles unread filter', (tester) async { - await tester.pumpWidget(buildChips()); + testWidgets('view selection sheet updates the shared view mode', (tester) async { + await pumpChips(tester); - await tester.tap(find.text('Unread')); + await tester.tap(find.text(LibraryViewMode.smallGrid.label)); await tester.pumpAndSettle(); + expect(find.text('View mode'), findsOneWidget); - expect(libraryProvider.isFilterActive(LibraryFilterType.unread), true); - }); - - testWidgets('tapping "All" chip resets all filters', (tester) async { - // First set some filters - libraryProvider.addFilter(LibraryFilterType.reading); - libraryProvider.addFilter(LibraryFilterType.favorites); - - await tester.pumpWidget(buildChips()); - - await tester.tap(find.text('All')); + await tester.tap(find.text(LibraryViewMode.list.label).last); await tester.pumpAndSettle(); - expect(libraryProvider.isFilterActive(LibraryFilterType.all), true); - expect(libraryProvider.isFilterActive(LibraryFilterType.reading), false); - expect(libraryProvider.isFilterActive(LibraryFilterType.favorites), false); + expect(libraryProvider.viewMode, LibraryViewMode.list); }); - testWidgets('tapping a filter chip twice deactivates it', (tester) async { - await tester.pumpWidget(buildChips()); - - // Tap to activate - await tester.tap(find.text('Reading')); - await tester.pumpAndSettle(); - expect(libraryProvider.isFilterActive(LibraryFilterType.reading), true); + testWidgets('Clear all resets filters, sort, and view', (tester) async { + libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); + libraryProvider.setSortOption(LibrarySortOption.titleAZ); + libraryProvider.setViewMode(LibraryViewMode.list); - // Tap to deactivate - await tester.tap(find.text('Reading')); + await pumpChips(tester); + await tester.tap(find.text('Clear all')); await tester.pumpAndSettle(); - expect(libraryProvider.isFilterActive(LibraryFilterType.reading), false); - // Should fall back to "all" - expect(libraryProvider.isFilterActive(LibraryFilterType.all), true); - }); - - testWidgets('filter chip icons are present', (tester) async { - await tester.pumpWidget(buildChips()); - - expect(find.byIcon(Icons.apps), findsOneWidget); - expect(find.byIcon(Icons.auto_stories), findsOneWidget); - expect(find.byIcon(Icons.favorite), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsOneWidget); - expect(find.byIcon(Icons.book), findsOneWidget); - }); - - testWidgets('chips are horizontally scrollable', (tester) async { - await tester.pumpWidget(buildChips()); - - // ListView should be present with horizontal scrolling - final listView = tester.widget(find.byType(ListView)); - expect(listView.scrollDirection, Axis.horizontal); - }); - - testWidgets('does not show Downloading by default', (tester) async { - await tester.pumpWidget(buildChips()); - - expect(find.text('Downloading'), findsNothing); - }); - - testWidgets('shows Downloading after the library filters when requested', (tester) async { - await tester.pumpWidget(buildChips(showDownloading: true)); - - final chips = tester.widget(find.byType(QuickFilterChips)); - final labels = chips.filters.map((filter) => filter.label).toList(); - - expect(labels, ['All', 'Reading', 'Favorites', 'Finished', 'Unread', 'Downloading']); - }); - - testWidgets('shows Downloading as the only selected chip', (tester) async { - await tester.pumpWidget(buildChips(showDownloading: true, isDownloadingSelected: true)); - - final chips = tester.widget(find.byType(QuickFilterChips)); - final allChip = chips.filters.first; - final downloadingChip = chips.filters.last; - - expect(downloadingChip.isSelected, isTrue); - expect(allChip.isSelected, isFalse); - expect(chips.filters.where((filter) => filter.isSelected), [downloadingChip]); - }); - - testWidgets('selecting Downloading resets library filters before dispatching its callback', (tester) async { - var downloadingTaps = 0; - var libraryFilterTaps = 0; - libraryProvider.addFilter(LibraryFilterType.reading); - - await tester.pumpWidget( - buildChips( - showDownloading: true, - onDownloadingTapped: () => downloadingTaps++, - onLibraryFilterTapped: () => libraryFilterTaps++, - ), - ); - - final chips = tester.widget(find.byType(QuickFilterChips)); - chips.onFilterTapped(chips.filters.length - 1); - await tester.pump(); - - expect(downloadingTaps, 1); - expect(libraryFilterTaps, 0); - expect(libraryProvider.activeFilters, {LibraryFilterType.all}); - }); - - testWidgets('dispatches ordinary chips through the provider and callback', (tester) async { - var downloadingTaps = 0; - var libraryFilterTaps = 0; - - await tester.pumpWidget( - buildChips( - showDownloading: true, - onDownloadingTapped: () => downloadingTaps++, - onLibraryFilterTapped: () => libraryFilterTaps++, - ), - ); - - await tester.tap(find.text('Reading')); - await tester.pump(); - expect(downloadingTaps, 0); - expect(libraryFilterTaps, 1); - expect(libraryProvider.activeFilters, {LibraryFilterType.reading}); + expect(libraryProvider.filters.isEmpty, isTrue); + expect(libraryProvider.sortOption, LibrarySortOption.dateAddedNewest); + expect(libraryProvider.viewMode, LibraryViewMode.smallGrid); }); }); } diff --git a/app/test/widgets/search/library_search_bar_test.dart b/app/test/widgets/search/library_search_bar_test.dart index 0bfffe7..ecd12f5 100644 --- a/app/test/widgets/search/library_search_bar_test.dart +++ b/app/test/widgets/search/library_search_bar_test.dart @@ -36,8 +36,8 @@ void main() { }); testWidgets('displays filter button', (tester) async { - await tester.pumpWidget(buildSearchBar()); - expect(find.byIcon(Icons.tune), findsOneWidget); + await tester.pumpWidget(buildSearchBar(onFilterTap: () {})); + expect(find.byIcon(Icons.tune_rounded), findsOneWidget); }); testWidgets('calls onQueryChanged when text is entered', (tester) async { @@ -54,14 +54,14 @@ void main() { var filterTapped = false; await tester.pumpWidget(buildSearchBar(onFilterTap: () => filterTapped = true)); - await tester.tap(find.byIcon(Icons.tune)); + await tester.tap(find.byIcon(Icons.tune_rounded)); await tester.pump(); expect(filterTapped, true); }); testWidgets('shows filter badge when activeFilterCount > 0', (tester) async { - await tester.pumpWidget(buildSearchBar(activeFilterCount: 3)); + await tester.pumpWidget(buildSearchBar(activeFilterCount: 3, onFilterTap: () {})); expect(find.text('3'), findsOneWidget); }); @@ -103,136 +103,6 @@ void main() { expect(find.byType(TextField), findsOneWidget); }); - group('suggestions', () { - testWidgets('shows field suggestions when typing a field prefix', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - // Focus and type a field prefix - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'auth'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Should show 'author:' suggestion in the overlay - expect(find.text('author:'), findsOneWidget); - }); - - testWidgets('shows status suggestions when typing status:', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'status:'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Should show status value suggestions - expect(find.text('status:reading'), findsOneWidget); - expect(find.text('status:finished'), findsOneWidget); - expect(find.text('status:unread'), findsOneWidget); - }); - - testWidgets('shows format suggestions when typing format:', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'format:'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Should show format value suggestions - expect(find.text('format:epub'), findsOneWidget); - expect(find.text('format:pdf'), findsOneWidget); - }); - - testWidgets('applies suggestion when tapped', (tester) async { - String? lastQuery; - await tester.pumpWidget(buildSearchBar(onQueryChanged: (q) => lastQuery = q)); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'auth'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Tap the suggestion - await tester.tap(find.text('author:')); - await tester.pump(); - - expect(lastQuery, 'author:'); - }); - - testWidgets('hides suggestions when text is cleared', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'auth'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Verify suggestions are showing - expect(find.text('author:'), findsOneWidget); - - // Clear text - await tester.enterText(find.byType(TextField), ''); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Suggestions should be gone - expect(find.text('author:'), findsNothing); - }); - - testWidgets('does not show suggestions for non-matching text', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'xyz'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // No field suggestions should match 'xyz' - expect(find.text('author:'), findsNothing); - expect(find.text('title:'), findsNothing); - }); - - testWidgets('shows multiple field suggestions for partial match', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - // 's' matches 'shelf:', 'status:' - await tester.enterText(find.byType(TextField), 's'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.text('shelf:'), findsOneWidget); - expect(find.text('status:'), findsOneWidget); - }); - }); - - group('focus behavior', () { - testWidgets('suggestions appear only when focused', (tester) async { - await tester.pumpWidget(buildSearchBar()); - - // Initially no suggestions - expect(find.text('author:'), findsNothing); - - // Focus and type - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'auth'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Suggestions should appear - expect(find.text('author:'), findsOneWidget); - }); - }); - group('didUpdateWidget', () { testWidgets('updates text when initialQuery changes and not focused', (tester) async { // Start with empty query 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 new file mode 100644 index 0000000..8e524d6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-advanced-filter-sheet-sizing.md @@ -0,0 +1,105 @@ +# 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-reusable-shelf-books-page.md b/docs/superpowers/plans/2026-08-01-reusable-shelf-books-page.md new file mode 100644 index 0000000..7575b2a --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-reusable-shelf-books-page.md @@ -0,0 +1,754 @@ +# 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 new file mode 100644 index 0000000..6be0a8d --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-shelf-page-heading.md @@ -0,0 +1,157 @@ +# 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 new file mode 100644 index 0000000..3a5e3c6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-shelves-page-controls.md @@ -0,0 +1,270 @@ +# 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/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 new file mode 100644 index 0000000..db9c1b5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-advanced-library-filters-visual-redesign-design.md @@ -0,0 +1,97 @@ +# 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 new file mode 100644 index 0000000..c030473 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-advanced-filter-sheet-sizing-design.md @@ -0,0 +1,45 @@ +# 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-reusable-shelf-books-page-design.md b/docs/superpowers/specs/2026-08-01-reusable-shelf-books-page-design.md new file mode 100644 index 0000000..d21b8b0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-reusable-shelf-books-page-design.md @@ -0,0 +1,164 @@ +# 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 new file mode 100644 index 0000000..e3dfaf2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-shelf-page-heading-design.md @@ -0,0 +1,69 @@ +# 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 new file mode 100644 index 0000000..b21956e --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-shelves-page-controls-design.md @@ -0,0 +1,107 @@ +# 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.