Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3da7318
PPR-25: Refactor modal bottom sheets to use root navigator for backdr…
Eoic Jul 27, 2026
1249e28
PPR-25: Remove dead code for book context menu on desktop view
Eoic Jul 27, 2026
aa99385
PPR-25: Redesign library page chips row.
Eoic Jul 28, 2026
4805b38
PPR-25: Simplify selection sheet implementation and remove unused scr…
Eoic Jul 29, 2026
8fb9309
PPR-25: Add library metadata filter chips
Eoic Jul 29, 2026
388a43b
PPR-25: Define advanced filter visual redesign
Eoic Jul 29, 2026
23d77cb
PPR-25: Add advanced library filters
Eoic Jul 30, 2026
3584012
PPR-25: Define shelves page controls
Eoic Aug 1, 2026
9f861ea
PPR-25: Add shelves page controls
Eoic Aug 1, 2026
cce6571
PPR-25: Define reusable shelf books page
Eoic Aug 1, 2026
82c29f0
PPR-25: Refine shelf books header design
Eoic Aug 1, 2026
9e73baf
PPR-25: Plan reusable shelf books page
Eoic Aug 1, 2026
2e54d56
PPR-25: Add filter chips to shelves page.
Eoic Aug 1, 2026
d32172e
PPR-25: Scope library filter options
Eoic Aug 1, 2026
4a18cf6
PPR-25: Optimize scoped filter relations
Eoic Aug 1, 2026
dedad74
PPR-25: Add scoped library filter inputs
Eoic Aug 1, 2026
cd844ff
PPR-25: Preserve unscoped filter fallback
Eoic Aug 1, 2026
e832ee1
PPR-25: Retain unavailable filter selections
Eoic Aug 1, 2026
5caa12e
PPR-25: Share favorite state with shelf views
Eoic Aug 1, 2026
af5fc54
PPR-25: Allow clearing shelf descriptions
Eoic Aug 1, 2026
90066d7
PPR-25: Preserve descriptions in partial shelf updates
Eoic Aug 1, 2026
e97c363
PPR-25: Reuse books page for collections
Eoic Aug 1, 2026
b320d82
PPR-25: Preserve main library filter options
Eoic Aug 1, 2026
229caa4
PPR-25: Delegate shelf books to library page
Eoic Aug 1, 2026
b0c5ed2
PPR-25: Remove legacy shelf book controls
Eoic Aug 1, 2026
f812486
PPR-25: Preserve main library filter facets
Eoic Aug 1, 2026
2ae30fd
PPR-25: Define shelf page heading
Eoic Aug 1, 2026
e7ef201
PPR-25: Plan shelf page heading
Eoic Aug 1, 2026
045fb55
PPR-25: Refine shelf page heading
Eoic Aug 1, 2026
9eff606
PPR-25: Label shelf edit action
Eoic Aug 1, 2026
a010d86
PPR-25: Define advanced filter sheet sizing
Eoic Aug 1, 2026
7d4b450
PPR-25: Plan advanced filter sheet sizing
Eoic Aug 1, 2026
0a08e3a
PPR-25: Align advanced filter sheet sizing
Eoic Aug 1, 2026
b2d5021
PPR-25: Simplify shelf page header
Eoic Aug 1, 2026
8a25e87
PPR-25: Update library filtering tests
Eoic Aug 1, 2026
376c7c0
PPR-25: Support CI Flutter version
Eoic Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions app/lib/data/sample_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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',
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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),
),
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
50 changes: 5 additions & 45 deletions app/lib/models/book.dart
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -230,7 +190,7 @@ class Book {
bool clearLentTo = false,
DateTime? lentAt,
bool clearLentAt = false,
ReadingStatus? readingStatus,
LibraryReadingStatus? readingStatus,
int? currentPage,
double? currentPosition,
String? currentCfi,
Expand Down Expand Up @@ -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?,
Expand Down
135 changes: 135 additions & 0 deletions app/lib/models/library_filter_options.dart
Original file line number Diff line number Diff line change
@@ -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<T> {
final T value;
final String label;

const LibraryFilterOption({required this.value, required this.label});
}

class LibraryFilterOptions {
final List<LibraryFilterOption<String>> authors;
final List<LibraryFilterOption<String>> languages;
final List<LibraryFilterOption<String>> formats;
final List<LibraryFilterOption<String>> topics;
final List<LibraryFilterOption<String>> shelves;
final List<LibraryFilterOption<String>> publishers;
final List<LibraryFilterOption<String>> series;
final List<LibraryFilterOption<LibraryReadingStatus>> readingStatuses;
final List<int> 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<Book>? books}) {
final isScoped = books != null;
final sourceBooks = books ?? dataStore.books;
final authors = <String, String>{};
final languages = <String, String>{};
final formats = <String, String>{};
final publishers = <String, String>{};
final series = <String, String>{};
final sourceBookIds = <String>{};
final topicIds = <String>{};
final shelfIds = <String>{};
final readingStatuses = <LibraryReadingStatus>{};
final ratings = <int>{};
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<String, String> labelsByValue, String? value) {
final label = value?.trim();
if (label == null || label.isEmpty) {
return;
}

labelsByValue.putIfAbsent(label.toLowerCase(), () => label);
}

static List<LibraryFilterOption<String>> _stringOptions(Map<String, String> labelsByValue) {
return _sortedOptions(
labelsByValue.entries.map((entry) => LibraryFilterOption(value: entry.key, label: entry.value)),
);
}

static List<LibraryFilterOption<String>> _sortedOptions(Iterable<LibraryFilterOption<String>> options) {
return options.toList()..sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase()));
}
}
Loading
Loading