diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 7509f30..d5ebcf1 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,4 +1,3 @@ -import 'package:bookmarkedapp/data/models/library_entry.dart'; import 'package:bookmarkedapp/screens/book_detail/book_detail_screen.dart'; import 'package:bookmarkedapp/screens/home/home_screen.dart'; import 'package:bookmarkedapp/screens/library/library_screen.dart'; @@ -60,17 +59,6 @@ final appRouter = GoRouter( return BookDetailScreen(bookId: id); }, ), - GoRoute( - path: '/library/status/:status', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { - final statusName = state.pathParameters['status']!; - return LibraryScreen( - filterStatus: ReadingStatus.fromDbValue(statusName), - showBackButton: true, - ); - }, - ), ], ); diff --git a/lib/data/database/app_database.dart b/lib/data/database/app_database.dart index 37e64db..f51c54d 100644 --- a/lib/data/database/app_database.dart +++ b/lib/data/database/app_database.dart @@ -15,6 +15,9 @@ class BookRecords extends Table { TextColumn get coverUrl => text().nullable()(); IntColumn get pageCount => integer().nullable()(); TextColumn get description => text().nullable()(); + IntColumn get publishedYear => integer().nullable()(); + TextColumn get genres => text().nullable()(); + TextColumn get publisher => text().nullable()(); @override Set> get primaryKey => {id}; @@ -38,7 +41,18 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (migrator, from, to) async { + if (from < 2) { + await migrator.addColumn(bookRecords, bookRecords.publishedYear); + await migrator.addColumn(bookRecords, bookRecords.genres); + await migrator.addColumn(bookRecords, bookRecords.publisher); + } + }, + ); } LazyDatabase _openConnection() { diff --git a/lib/data/database/app_database.g.dart b/lib/data/database/app_database.g.dart index b64ca0c..5a48a6e 100644 --- a/lib/data/database/app_database.g.dart +++ b/lib/data/database/app_database.g.dart @@ -80,6 +80,37 @@ class $BookRecordsTable extends BookRecords type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _publishedYearMeta = const VerificationMeta( + 'publishedYear', + ); + @override + late final GeneratedColumn publishedYear = GeneratedColumn( + 'published_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _genresMeta = const VerificationMeta('genres'); + @override + late final GeneratedColumn genres = GeneratedColumn( + 'genres', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _publisherMeta = const VerificationMeta( + 'publisher', + ); + @override + late final GeneratedColumn publisher = GeneratedColumn( + 'publisher', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -89,6 +120,9 @@ class $BookRecordsTable extends BookRecords coverUrl, pageCount, description, + publishedYear, + genres, + publisher, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -150,6 +184,27 @@ class $BookRecordsTable extends BookRecords ), ); } + if (data.containsKey('published_year')) { + context.handle( + _publishedYearMeta, + publishedYear.isAcceptableOrUnknown( + data['published_year']!, + _publishedYearMeta, + ), + ); + } + if (data.containsKey('genres')) { + context.handle( + _genresMeta, + genres.isAcceptableOrUnknown(data['genres']!, _genresMeta), + ); + } + if (data.containsKey('publisher')) { + context.handle( + _publisherMeta, + publisher.isAcceptableOrUnknown(data['publisher']!, _publisherMeta), + ); + } return context; } @@ -187,6 +242,18 @@ class $BookRecordsTable extends BookRecords DriftSqlType.string, data['${effectivePrefix}description'], ), + publishedYear: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}published_year'], + ), + genres: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}genres'], + ), + publisher: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}publisher'], + ), ); } @@ -204,6 +271,9 @@ class BookRecord extends DataClass implements Insertable { final String? coverUrl; final int? pageCount; final String? description; + final int? publishedYear; + final String? genres; + final String? publisher; const BookRecord({ required this.id, required this.title, @@ -212,6 +282,9 @@ class BookRecord extends DataClass implements Insertable { this.coverUrl, this.pageCount, this.description, + this.publishedYear, + this.genres, + this.publisher, }); @override Map toColumns(bool nullToAbsent) { @@ -231,6 +304,15 @@ class BookRecord extends DataClass implements Insertable { if (!nullToAbsent || description != null) { map['description'] = Variable(description); } + if (!nullToAbsent || publishedYear != null) { + map['published_year'] = Variable(publishedYear); + } + if (!nullToAbsent || genres != null) { + map['genres'] = Variable(genres); + } + if (!nullToAbsent || publisher != null) { + map['publisher'] = Variable(publisher); + } return map; } @@ -249,6 +331,15 @@ class BookRecord extends DataClass implements Insertable { description: description == null && nullToAbsent ? const Value.absent() : Value(description), + publishedYear: publishedYear == null && nullToAbsent + ? const Value.absent() + : Value(publishedYear), + genres: genres == null && nullToAbsent + ? const Value.absent() + : Value(genres), + publisher: publisher == null && nullToAbsent + ? const Value.absent() + : Value(publisher), ); } @@ -265,6 +356,9 @@ class BookRecord extends DataClass implements Insertable { coverUrl: serializer.fromJson(json['coverUrl']), pageCount: serializer.fromJson(json['pageCount']), description: serializer.fromJson(json['description']), + publishedYear: serializer.fromJson(json['publishedYear']), + genres: serializer.fromJson(json['genres']), + publisher: serializer.fromJson(json['publisher']), ); } @override @@ -278,6 +372,9 @@ class BookRecord extends DataClass implements Insertable { 'coverUrl': serializer.toJson(coverUrl), 'pageCount': serializer.toJson(pageCount), 'description': serializer.toJson(description), + 'publishedYear': serializer.toJson(publishedYear), + 'genres': serializer.toJson(genres), + 'publisher': serializer.toJson(publisher), }; } @@ -289,6 +386,9 @@ class BookRecord extends DataClass implements Insertable { Value coverUrl = const Value.absent(), Value pageCount = const Value.absent(), Value description = const Value.absent(), + Value publishedYear = const Value.absent(), + Value genres = const Value.absent(), + Value publisher = const Value.absent(), }) => BookRecord( id: id ?? this.id, title: title ?? this.title, @@ -297,6 +397,11 @@ class BookRecord extends DataClass implements Insertable { coverUrl: coverUrl.present ? coverUrl.value : this.coverUrl, pageCount: pageCount.present ? pageCount.value : this.pageCount, description: description.present ? description.value : this.description, + publishedYear: publishedYear.present + ? publishedYear.value + : this.publishedYear, + genres: genres.present ? genres.value : this.genres, + publisher: publisher.present ? publisher.value : this.publisher, ); BookRecord copyWithCompanion(BookRecordsCompanion data) { return BookRecord( @@ -309,6 +414,11 @@ class BookRecord extends DataClass implements Insertable { description: data.description.present ? data.description.value : this.description, + publishedYear: data.publishedYear.present + ? data.publishedYear.value + : this.publishedYear, + genres: data.genres.present ? data.genres.value : this.genres, + publisher: data.publisher.present ? data.publisher.value : this.publisher, ); } @@ -321,14 +431,27 @@ class BookRecord extends DataClass implements Insertable { ..write('isbn: $isbn, ') ..write('coverUrl: $coverUrl, ') ..write('pageCount: $pageCount, ') - ..write('description: $description') + ..write('description: $description, ') + ..write('publishedYear: $publishedYear, ') + ..write('genres: $genres, ') + ..write('publisher: $publisher') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(id, title, authors, isbn, coverUrl, pageCount, description); + int get hashCode => Object.hash( + id, + title, + authors, + isbn, + coverUrl, + pageCount, + description, + publishedYear, + genres, + publisher, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -339,7 +462,10 @@ class BookRecord extends DataClass implements Insertable { other.isbn == this.isbn && other.coverUrl == this.coverUrl && other.pageCount == this.pageCount && - other.description == this.description); + other.description == this.description && + other.publishedYear == this.publishedYear && + other.genres == this.genres && + other.publisher == this.publisher); } class BookRecordsCompanion extends UpdateCompanion { @@ -350,6 +476,9 @@ class BookRecordsCompanion extends UpdateCompanion { final Value coverUrl; final Value pageCount; final Value description; + final Value publishedYear; + final Value genres; + final Value publisher; final Value rowid; const BookRecordsCompanion({ this.id = const Value.absent(), @@ -359,6 +488,9 @@ class BookRecordsCompanion extends UpdateCompanion { this.coverUrl = const Value.absent(), this.pageCount = const Value.absent(), this.description = const Value.absent(), + this.publishedYear = const Value.absent(), + this.genres = const Value.absent(), + this.publisher = const Value.absent(), this.rowid = const Value.absent(), }); BookRecordsCompanion.insert({ @@ -369,6 +501,9 @@ class BookRecordsCompanion extends UpdateCompanion { this.coverUrl = const Value.absent(), this.pageCount = const Value.absent(), this.description = const Value.absent(), + this.publishedYear = const Value.absent(), + this.genres = const Value.absent(), + this.publisher = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), title = Value(title), @@ -381,6 +516,9 @@ class BookRecordsCompanion extends UpdateCompanion { Expression? coverUrl, Expression? pageCount, Expression? description, + Expression? publishedYear, + Expression? genres, + Expression? publisher, Expression? rowid, }) { return RawValuesInsertable({ @@ -391,6 +529,9 @@ class BookRecordsCompanion extends UpdateCompanion { if (coverUrl != null) 'cover_url': coverUrl, if (pageCount != null) 'page_count': pageCount, if (description != null) 'description': description, + if (publishedYear != null) 'published_year': publishedYear, + if (genres != null) 'genres': genres, + if (publisher != null) 'publisher': publisher, if (rowid != null) 'rowid': rowid, }); } @@ -403,6 +544,9 @@ class BookRecordsCompanion extends UpdateCompanion { Value? coverUrl, Value? pageCount, Value? description, + Value? publishedYear, + Value? genres, + Value? publisher, Value? rowid, }) { return BookRecordsCompanion( @@ -413,6 +557,9 @@ class BookRecordsCompanion extends UpdateCompanion { coverUrl: coverUrl ?? this.coverUrl, pageCount: pageCount ?? this.pageCount, description: description ?? this.description, + publishedYear: publishedYear ?? this.publishedYear, + genres: genres ?? this.genres, + publisher: publisher ?? this.publisher, rowid: rowid ?? this.rowid, ); } @@ -441,6 +588,15 @@ class BookRecordsCompanion extends UpdateCompanion { if (description.present) { map['description'] = Variable(description.value); } + if (publishedYear.present) { + map['published_year'] = Variable(publishedYear.value); + } + if (genres.present) { + map['genres'] = Variable(genres.value); + } + if (publisher.present) { + map['publisher'] = Variable(publisher.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -457,6 +613,9 @@ class BookRecordsCompanion extends UpdateCompanion { ..write('coverUrl: $coverUrl, ') ..write('pageCount: $pageCount, ') ..write('description: $description, ') + ..write('publishedYear: $publishedYear, ') + ..write('genres: $genres, ') + ..write('publisher: $publisher, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -967,6 +1126,9 @@ typedef $$BookRecordsTableCreateCompanionBuilder = Value coverUrl, Value pageCount, Value description, + Value publishedYear, + Value genres, + Value publisher, Value rowid, }); typedef $$BookRecordsTableUpdateCompanionBuilder = @@ -978,6 +1140,9 @@ typedef $$BookRecordsTableUpdateCompanionBuilder = Value coverUrl, Value pageCount, Value description, + Value publishedYear, + Value genres, + Value publisher, Value rowid, }); @@ -1024,6 +1189,21 @@ class $$BookRecordsTableFilterComposer column: $table.description, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get publishedYear => $composableBuilder( + column: $table.publishedYear, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get genres => $composableBuilder( + column: $table.genres, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get publisher => $composableBuilder( + column: $table.publisher, + builder: (column) => ColumnFilters(column), + ); } class $$BookRecordsTableOrderingComposer @@ -1069,6 +1249,21 @@ class $$BookRecordsTableOrderingComposer column: $table.description, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get publishedYear => $composableBuilder( + column: $table.publishedYear, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get genres => $composableBuilder( + column: $table.genres, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get publisher => $composableBuilder( + column: $table.publisher, + builder: (column) => ColumnOrderings(column), + ); } class $$BookRecordsTableAnnotationComposer @@ -1102,6 +1297,17 @@ class $$BookRecordsTableAnnotationComposer column: $table.description, builder: (column) => column, ); + + GeneratedColumn get publishedYear => $composableBuilder( + column: $table.publishedYear, + builder: (column) => column, + ); + + GeneratedColumn get genres => + $composableBuilder(column: $table.genres, builder: (column) => column); + + GeneratedColumn get publisher => + $composableBuilder(column: $table.publisher, builder: (column) => column); } class $$BookRecordsTableTableManager @@ -1142,6 +1348,9 @@ class $$BookRecordsTableTableManager Value coverUrl = const Value.absent(), Value pageCount = const Value.absent(), Value description = const Value.absent(), + Value publishedYear = const Value.absent(), + Value genres = const Value.absent(), + Value publisher = const Value.absent(), Value rowid = const Value.absent(), }) => BookRecordsCompanion( id: id, @@ -1151,6 +1360,9 @@ class $$BookRecordsTableTableManager coverUrl: coverUrl, pageCount: pageCount, description: description, + publishedYear: publishedYear, + genres: genres, + publisher: publisher, rowid: rowid, ), createCompanionCallback: @@ -1162,6 +1374,9 @@ class $$BookRecordsTableTableManager Value coverUrl = const Value.absent(), Value pageCount = const Value.absent(), Value description = const Value.absent(), + Value publishedYear = const Value.absent(), + Value genres = const Value.absent(), + Value publisher = const Value.absent(), Value rowid = const Value.absent(), }) => BookRecordsCompanion.insert( id: id, @@ -1171,6 +1386,9 @@ class $$BookRecordsTableTableManager coverUrl: coverUrl, pageCount: pageCount, description: description, + publishedYear: publishedYear, + genres: genres, + publisher: publisher, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/data/models/book.dart b/lib/data/models/book.dart index e7250ea..a5089fd 100644 --- a/lib/data/models/book.dart +++ b/lib/data/models/book.dart @@ -7,6 +7,9 @@ class Book { this.coverUrl, this.pageCount, this.description, + this.publishedYear, + this.genres = const [], + this.publisher, }); final String id; @@ -16,10 +19,30 @@ class Book { final String? coverUrl; final int? pageCount; final String? description; + final int? publishedYear; + final List genres; + final String? publisher; String get authorsDisplay => authors.isEmpty ? 'Unknown author' : authors.join(', '); + String get genresDisplay => + genres.isEmpty ? '' : genres.take(5).join(', '); + + String? get plainDescription { + final text = description; + if (text == null || text.trim().isEmpty) return null; + return _stripHtml(text); + } + + bool get hasMetadata => + plainDescription != null || + publishedYear != null || + genres.isNotEmpty || + publisher != null || + pageCount != null || + isbn != null; + Book copyWith({ String? id, String? title, @@ -28,6 +51,9 @@ class Book { String? coverUrl, int? pageCount, String? description, + int? publishedYear, + List? genres, + String? publisher, }) { return Book( id: id ?? this.id, @@ -37,9 +63,65 @@ class Book { coverUrl: coverUrl ?? this.coverUrl, pageCount: pageCount ?? this.pageCount, description: description ?? this.description, + publishedYear: publishedYear ?? this.publishedYear, + genres: genres ?? this.genres, + publisher: publisher ?? this.publisher, ); } + Book mergeWith(Book other) { + return copyWith( + title: title == 'Untitled' ? other.title : title, + authors: authors.isEmpty ? other.authors : authors, + isbn: isbn ?? other.isbn, + coverUrl: coverUrl ?? other.coverUrl, + pageCount: pageCount ?? other.pageCount, + description: _preferLongerText(description, other.description), + publishedYear: publishedYear ?? other.publishedYear, + genres: genres.isNotEmpty ? genres : other.genres, + publisher: publisher ?? other.publisher, + ); + } + + static String? _preferLongerText(String? a, String? b) { + if (a == null || a.trim().isEmpty) return b; + if (b == null || b.trim().isEmpty) return a; + return a.length >= b.length ? a : b; + } + + static String _stripHtml(String html) { + return html + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + + static List normalizeGenres(List? values) { + if (values == null) return const []; + + final genres = []; + for (final value in values) { + final text = value?.toString().trim(); + if (text == null || text.isEmpty) continue; + if (!genres.contains(text)) genres.add(text); + if (genres.length >= 8) break; + } + return genres; + } + + static int? parsePublishYear(dynamic value) { + if (value == null) return null; + if (value is int) return value; + final match = RegExp(r'\d{4}').firstMatch(value.toString()); + return match != null ? int.tryParse(match.group(0)!) : null; + } + factory Book.fromJson(Map json) { final volumeInfo = json['volumeInfo'] as Map? ?? {}; final industryIdentifiers = @@ -71,6 +153,9 @@ class Book { coverUrl: coverUrl, pageCount: volumeInfo['pageCount'] as int?, description: volumeInfo['description'] as String?, + publishedYear: parsePublishYear(volumeInfo['publishedDate']), + genres: normalizeGenres(volumeInfo['categories'] as List?), + publisher: volumeInfo['publisher'] as String?, ); } @@ -95,6 +180,10 @@ class Book { final firstSentence = json['first_sentence'] as List?; final description = firstSentence?.firstOrNull?.toString(); + final publishers = (json['publisher'] as List?) + ?.map((e) => e.toString()) + .where((name) => name.isNotEmpty) + .toList(); return Book( id: id, @@ -106,6 +195,9 @@ class Book { : null, pageCount: json['number_of_pages_median'] as int?, description: description, + publishedYear: parsePublishYear(json['first_publish_year']), + genres: normalizeGenres(json['subject'] as List?), + publisher: publishers?.firstOrNull, ); } @@ -118,11 +210,15 @@ class Book { 'cover_url': coverUrl, 'page_count': pageCount, 'description': description, + 'published_year': publishedYear, + 'genres': genres.join('|'), + 'publisher': publisher, }; } factory Book.fromDbMap(Map map) { final authorsStr = map['authors'] as String? ?? ''; + final genresStr = map['genres'] as String? ?? ''; return Book( id: map['id'] as String, title: map['title'] as String, @@ -133,6 +229,11 @@ class Book { coverUrl: map['cover_url'] as String?, pageCount: map['page_count'] as int?, description: map['description'] as String?, + publishedYear: map['published_year'] as int?, + genres: genresStr.isEmpty + ? const [] + : genresStr.split('|').where((g) => g.isNotEmpty).toList(), + publisher: map['publisher'] as String?, ); } } diff --git a/lib/data/models/library_entry.dart b/lib/data/models/library_entry.dart index a89ff98..5365367 100644 --- a/lib/data/models/library_entry.dart +++ b/lib/data/models/library_entry.dart @@ -106,4 +106,23 @@ class LibraryBook { } return 0; } + + String get progressPageLabel { + if (entry.status == ReadingStatus.finished) { + if (book.pageCount != null) { + return 'Finished ยท ${book.pageCount} pages'; + } + return 'Finished'; + } + if (entry.currentPage != null && book.pageCount != null) { + return 'Page ${entry.currentPage} of ${book.pageCount}'; + } + if (entry.currentPage != null) { + return 'Page ${entry.currentPage} read'; + } + if (entry.status == ReadingStatus.wantToRead) { + return 'Not started'; + } + return 'No progress yet'; + } } diff --git a/lib/data/repositories/library_repository.dart b/lib/data/repositories/library_repository.dart index b354353..10a07c9 100644 --- a/lib/data/repositories/library_repository.dart +++ b/lib/data/repositories/library_repository.dart @@ -53,6 +53,24 @@ class LibraryRepository { Book book, { ReadingStatus status = ReadingStatus.wantToRead, }) async { + await _upsertBook(book); + + final now = DateTime.now().millisecondsSinceEpoch; + await _db.into(_db.libraryEntryRecords).insertOnConflictUpdate( + LibraryEntryRecordsCompanion( + bookId: Value(book.id), + status: Value(status.name), + addedAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + Future updateBookMetadata(Book book) async { + await _upsertBook(book); + } + + Future _upsertBook(Book book) async { await _db.into(_db.bookRecords).insertOnConflictUpdate( BookRecordsCompanion( id: Value(book.id), @@ -62,16 +80,11 @@ class LibraryRepository { coverUrl: Value(book.coverUrl), pageCount: Value(book.pageCount), description: Value(book.description), - ), - ); - - final now = DateTime.now().millisecondsSinceEpoch; - await _db.into(_db.libraryEntryRecords).insertOnConflictUpdate( - LibraryEntryRecordsCompanion( - bookId: Value(book.id), - status: Value(status.name), - addedAt: Value(now), - updatedAt: Value(now), + publishedYear: Value(book.publishedYear), + genres: Value( + book.genres.isEmpty ? null : book.genres.join('|'), + ), + publisher: Value(book.publisher), ), ); } @@ -142,15 +155,7 @@ class LibraryRepository { .getSingleOrNull(); if (row == null) return null; - return Book( - id: row.id, - title: row.title, - authors: row.authors.split('|').where((a) => a.isNotEmpty).toList(), - isbn: row.isbn, - coverUrl: row.coverUrl, - pageCount: row.pageCount, - description: row.description, - ); + return _bookFromRow(row); } Future> getStatusCounts() async { @@ -176,16 +181,7 @@ class LibraryRepository { final entryRow = row.readTable(_db.libraryEntryRecords); return LibraryBook( - book: Book( - id: bookRow.id, - title: bookRow.title, - authors: - bookRow.authors.split('|').where((a) => a.isNotEmpty).toList(), - isbn: bookRow.isbn, - coverUrl: bookRow.coverUrl, - pageCount: bookRow.pageCount, - description: bookRow.description, - ), + book: _bookFromRow(bookRow), entry: LibraryEntry( bookId: entryRow.bookId, status: ReadingStatus.fromDbValue(entryRow.status), @@ -197,4 +193,22 @@ class LibraryRepository { ), ); } + + Book _bookFromRow(BookRecord row) { + return Book( + id: row.id, + title: row.title, + authors: row.authors.split('|').where((a) => a.isNotEmpty).toList(), + isbn: row.isbn, + coverUrl: row.coverUrl, + pageCount: row.pageCount, + description: row.description, + publishedYear: row.publishedYear, + genres: (row.genres ?? '') + .split('|') + .where((genre) => genre.isNotEmpty) + .toList(), + publisher: row.publisher, + ); + } } diff --git a/lib/data/services/book_search_service.dart b/lib/data/services/book_search_service.dart index d791e47..b79bdb0 100644 --- a/lib/data/services/book_search_service.dart +++ b/lib/data/services/book_search_service.dart @@ -9,6 +9,7 @@ class BookSearchService { final http.Client _client; final _cache = >{}; + final _detailCache = {}; static const _googleBaseUrl = 'https://www.googleapis.com/books/v1/volumes'; static const _openLibrarySearchUrl = 'https://openlibrary.org/search.json'; @@ -21,7 +22,7 @@ class BookSearchService { }; static const _searchFields = - 'key,title,author_name,isbn,cover_i,number_of_pages_median,edition_key,first_sentence'; + 'key,title,author_name,isbn,cover_i,number_of_pages_median,edition_key,first_sentence,subject,first_publish_year,publisher'; _ParsedQuery _parseQuery(String input) { final trimmed = input.trim(); @@ -74,17 +75,30 @@ class BookSearchService { } Future getBookById(String id) async { + final cached = _detailCache[id]; + if (cached != null) return cached; + + Book? book; if (id.startsWith('ol-edition-')) { - return _fetchOpenLibraryEdition(id.substring('ol-edition-'.length)); - } - if (id.startsWith('ol-work-')) { - return _fetchOpenLibraryWork(id.substring('ol-work-'.length)); + book = await _fetchOpenLibraryEdition(id.substring('ol-edition-'.length)); + } else if (id.startsWith('ol-work-')) { + book = await _fetchOpenLibraryWork(id.substring('ol-work-'.length)); + } else if (id.startsWith('ol-')) { + book = await _fetchOpenLibraryLegacyId(id); + } else { + book = await _fetchGoogleBookById(id); } - if (id.startsWith('ol-')) { - return _fetchOpenLibraryLegacyId(id); + + if (book != null) { + _detailCache[id] = book; } + return book; + } - return _fetchGoogleBookById(id); + Future enrichBook(Book book) async { + final detailed = await getBookById(book.id); + if (detailed == null) return book; + return book.mergeWith(detailed); } Future> _searchOpenLibrary( @@ -164,7 +178,17 @@ class BookSearchService { if (response.statusCode != 200) return null; final data = json.decode(response.body) as Map; - return _bookFromOpenLibraryEdition(data, editionId); + var book = _bookFromOpenLibraryEdition(data, editionId); + + final workId = _extractWorkId(data['works']); + if (workId != null) { + final workBook = await _fetchOpenLibraryWork(workId); + if (workBook != null) { + book = book.mergeWith(workBook); + } + } + + return book; } Future _fetchOpenLibraryWork(String workId) async { @@ -174,13 +198,22 @@ class BookSearchService { final data = json.decode(response.body) as Map; final authors = await _resolveAuthorNames(data['authors']); + final subjects = (data['subjects'] as List?) + ?.map((subject) => subject.toString()) + .toList() ?? + []; return Book( id: 'ol-work-$workId', title: data['title'] as String? ?? 'Untitled', authors: authors, - coverUrl: null, + coverUrl: _coverUrlFromEdition(data), description: _extractDescription(data['description']), + publishedYear: Book.parsePublishYear( + data['first_publish_date'] ?? data['created']?['value'], + ), + genres: Book.normalizeGenres(subjects), + publisher: _firstPublisher(data['publishers']), ); } @@ -242,9 +275,29 @@ class BookSearchService { coverUrl: _coverUrlFromEdition(data), pageCount: data['number_of_pages'] as int?, description: _extractDescription(data['description']), + publishedYear: Book.parsePublishYear(data['publish_date']), + genres: Book.normalizeGenres(data['subjects'] ?? data['subject']), + publisher: _firstPublisher(data['publishers']), ); } + String? _extractWorkId(dynamic worksField) { + if (worksField is! List || worksField.isEmpty) return null; + final work = worksField.first; + final key = work is Map ? work['key']?.toString() : work?.toString(); + if (key == null) return null; + return key.split('/').last; + } + + String? _firstPublisher(dynamic publishersField) { + if (publishersField is! List || publishersField.isEmpty) return null; + final publisher = publishersField.first; + if (publisher is Map) { + return publisher['name']?.toString(); + } + return publisher?.toString(); + } + String? _coverUrlFromEdition(Map data) { final covers = data['covers'] as List?; if (covers != null && covers.isNotEmpty) { diff --git a/lib/screens/book_detail/book_detail_screen.dart b/lib/screens/book_detail/book_detail_screen.dart index 5a84470..abefd17 100644 --- a/lib/screens/book_detail/book_detail_screen.dart +++ b/lib/screens/book_detail/book_detail_screen.dart @@ -3,8 +3,10 @@ import 'package:bookmarkedapp/data/models/book.dart'; import 'package:bookmarkedapp/data/models/library_entry.dart'; import 'package:bookmarkedapp/providers/library_provider.dart'; import 'package:bookmarkedapp/widgets/book_cover.dart'; +import 'package:bookmarkedapp/widgets/book_info_sections.dart'; import 'package:bookmarkedapp/widgets/primary_button.dart'; import 'package:bookmarkedapp/widgets/progress_sheet.dart'; +import 'package:bookmarkedapp/widgets/remove_book_sheet.dart'; import 'package:bookmarkedapp/widgets/status_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,36 +21,48 @@ class BookDetailScreen extends ConsumerStatefulWidget { } class _BookDetailScreenState extends ConsumerState { - bool _descriptionExpanded = false; - Book? _fetchedBook; - bool _loadingBook = true; + Book? _enrichedBook; + bool _loadingDetails = true; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadBookIfNeeded()); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadBookDetails()); } - Future _loadBookIfNeeded() async { + Future _loadBookDetails() async { final libraryBook = ref.read(libraryEntryProvider(widget.bookId)).valueOrNull; - if (libraryBook != null) { - if (mounted) setState(() => _loadingBook = false); - return; - } - var book = await ref.read(libraryRepositoryProvider).getBook(widget.bookId); + var book = libraryBook?.book ?? + await ref.read(libraryRepositoryProvider).getBook(widget.bookId); book ??= await ref.read(bookSearchServiceProvider).getBookById(widget.bookId); + if (book != null) { + book = await ref.read(bookSearchServiceProvider).enrichBook(book); + if (libraryBook != null) { + await ref.read(libraryRepositoryProvider).updateBookMetadata(book); + } + } + if (mounted) { setState(() { - _fetchedBook = book; - _loadingBook = false; + _enrichedBook = book; + _loadingDetails = false; }); } } + Book _resolveBook(LibraryBook? libraryBook) { + final baseBook = libraryBook?.book ?? _enrichedBook; + if (baseBook == null) { + return Book(id: widget.bookId, title: 'Untitled', authors: const []); + } + if (_enrichedBook == null) return baseBook; + return baseBook.mergeWith(_enrichedBook!); + } + Future _addToLibrary(Book book) async { await ref.read(libraryRepositoryProvider).addBook(book); if (mounted) { @@ -62,6 +76,23 @@ class _BookDetailScreenState extends ConsumerState { await ref.read(libraryRepositoryProvider).updateRating(bookId, rating); } + void _showRemoveSheet(LibraryBook libraryBook) { + RemoveBookSheet.show( + context, + bookTitle: libraryBook.book.title, + onRemove: () async { + await ref + .read(libraryRepositoryProvider) + .removeFromLibrary(libraryBook.book.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Removed from library')), + ); + Navigator.of(context).pop(); + }, + ); + } + @override Widget build(BuildContext context) { final entryAsync = ref.watch(libraryEntryProvider(widget.bookId)); @@ -78,7 +109,7 @@ class _BookDetailScreenState extends ConsumerState { if (entryAsync.valueOrNull != null) IconButton( icon: const Icon(Icons.more_horiz), - onPressed: () => _showStatusSheet(entryAsync.value!), + onPressed: () => _showRemoveSheet(entryAsync.value!), ), ], ), @@ -86,12 +117,17 @@ class _BookDetailScreenState extends ConsumerState { loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (libraryBook) { - if (_loadingBook && libraryBook == null && _fetchedBook == null) { + if (_loadingDetails && + libraryBook == null && + _enrichedBook == null) { return const Center(child: CircularProgressIndicator()); } - final book = libraryBook?.book ?? _fetchedBook; - if (book == null) { + final book = _resolveBook(libraryBook); + if (book.title == 'Untitled' && + book.authors.isEmpty && + _enrichedBook == null && + !_loadingDetails) { return const Center(child: Text('Book not found')); } @@ -124,38 +160,24 @@ class _BookDetailScreenState extends ConsumerState { textAlign: TextAlign.center, ), if (inLibrary) ...[ + const SizedBox(height: 16), + BookLibraryStatusHeader( + libraryBook: libraryBook, + onStatusTap: () => _showStatusSheet(libraryBook), + ), const SizedBox(height: 16), _RatingRow( rating: rating, onRatingChanged: (r) => _updateRating(book.id, r), ), ], - if (book.description != null) ...[ - const SizedBox(height: 32), - Text( - 'About the book', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 12), - Text( - book.description!, - style: Theme.of(context).textTheme.bodyMedium, - maxLines: _descriptionExpanded ? null : 4, - overflow: - _descriptionExpanded ? null : TextOverflow.ellipsis, - ), - if (book.description!.length > 200) - TextButton( - onPressed: () { - setState(() { - _descriptionExpanded = !_descriptionExpanded; - }); - }, - child: Text( - _descriptionExpanded ? 'Show less' : 'Read more', - ), - ), - ], + const SizedBox(height: 16), + BookDetailsBadgeStrip(book: book), + const SizedBox(height: 24), + BookDescriptionSection( + book: book, + isLoading: _loadingDetails, + ), const SizedBox(height: 32), PrimaryButton( label: inLibrary ? 'Update Progress' : 'Add to Library', @@ -167,20 +189,6 @@ class _BookDetailScreenState extends ConsumerState { } }, ), - if (inLibrary) ...[ - const SizedBox(height: 12), - OutlinedButton( - onPressed: () => _showStatusSheet(libraryBook), - style: OutlinedButton.styleFrom( - minimumSize: const Size(double.infinity, 52), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radius), - ), - side: const BorderSide(color: Colors.black), - ), - child: const Text('Change Status'), - ), - ], ], ), ); @@ -216,12 +224,6 @@ class _BookDetailScreenState extends ConsumerState { .read(libraryRepositoryProvider) .updateStatus(libraryBook.book.id, status); }, - onRemove: () async { - await ref - .read(libraryRepositoryProvider) - .removeFromLibrary(libraryBook.book.id); - if (mounted) Navigator.of(context).pop(); - }, ); } } diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart index cbd4283..f704f71 100644 --- a/lib/screens/library/library_screen.dart +++ b/lib/screens/library/library_screen.dart @@ -7,20 +7,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class LibraryScreen extends ConsumerWidget { - const LibraryScreen({ - super.key, - this.filterStatus, - this.showBackButton = false, - }); - - final ReadingStatus? filterStatus; - final bool showBackButton; + const LibraryScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final booksAsync = filterStatus != null - ? ref.watch(libraryByStatusProvider(filterStatus!)) - : ref.watch(libraryProvider); + final booksAsync = ref.watch(libraryProvider); final statusCountsAsync = ref.watch(statusCountsProvider); return SafeArea( @@ -35,21 +26,13 @@ class LibraryScreen extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Row( children: [ - if (showBackButton) - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), Expanded( child: Text( - filterStatus != null - ? filterStatus!.label - : 'Your Library', + 'Your Library', style: Theme.of(context).textTheme.headlineMedium, ), ), - if (!showBackButton) - const Icon(Icons.tune, color: AppTheme.textSecondary), + const Icon(Icons.tune, color: AppTheme.textSecondary), ], ), ), @@ -61,9 +44,7 @@ class LibraryScreen extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(24), child: Text( - filterStatus != null - ? 'No books in this category' - : 'Your library is empty. Search for books to get started.', + 'Your library is empty. Search for books to get started.', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), @@ -85,35 +66,16 @@ class LibraryScreen extends ConsumerWidget { }, ), ), - if (filterStatus == null) - SliverToBoxAdapter( - child: statusCountsAsync.when( - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - data: (counts) => Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Categories', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - ...ReadingStatus.values.map((status) { - return _CategoryRow( - label: status.label, - count: counts[status] ?? 0, - onTap: () => context.push( - '/library/status/${status.name}', - ), - ); - }), - ], - ), - ), + SliverToBoxAdapter( + child: statusCountsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + data: (counts) => _CategoriesSection( + books: books, + counts: counts, ), ), + ), ], ); }, @@ -122,44 +84,143 @@ class LibraryScreen extends ConsumerWidget { } } -class _CategoryRow extends StatelessWidget { - const _CategoryRow({ +class _CategoriesSection extends StatefulWidget { + const _CategoriesSection({ + required this.books, + required this.counts, + }); + + final List books; + final Map counts; + + @override + State<_CategoriesSection> createState() => _CategoriesSectionState(); +} + +class _CategoriesSectionState extends State<_CategoriesSection> { + ReadingStatus? _expandedStatus; + + void _toggleCategory(ReadingStatus status) { + setState(() { + _expandedStatus = _expandedStatus == status ? null : status; + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Categories', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + ...ReadingStatus.values.map((status) { + final categoryBooks = widget.books + .where((book) => book.entry.status == status) + .toList(); + + return _ExpandableCategoryRow( + label: status.label, + count: widget.counts[status] ?? 0, + isExpanded: _expandedStatus == status, + books: categoryBooks, + onTap: () => _toggleCategory(status), + ); + }), + ], + ), + ); + } +} + +class _ExpandableCategoryRow extends StatelessWidget { + const _ExpandableCategoryRow({ required this.label, required this.count, + required this.isExpanded, + required this.books, required this.onTap, }); final String label; final int count; + final bool isExpanded; + final List books; final VoidCallback onTap; @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppTheme.radius), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.titleMedium, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Text( + '$count', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 8), + AnimatedRotation( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + turns: isExpanded ? 0.25 : 0, + child: const Icon( + Icons.chevron_right, + color: AppTheme.textSecondary, + ), + ), + ], ), ), - Text( - '$count', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(width: 8), - const Icon( - Icons.chevron_right, - color: AppTheme.textSecondary, - ), - ], + ), ), - ), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: isExpanded + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (books.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + 'No books in this category', + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else + ...books.map((item) { + return BookListTile( + libraryBook: item, + useCardStyle: true, + onTap: () => + context.push('/book/${item.book.id}'), + ); + }), + ], + ) + : const SizedBox(width: double.infinity), + ), + ], ); } } diff --git a/lib/screens/search/search_screen.dart b/lib/screens/search/search_screen.dart index 728384e..9c3ab86 100644 --- a/lib/screens/search/search_screen.dart +++ b/lib/screens/search/search_screen.dart @@ -5,6 +5,7 @@ import 'package:bookmarkedapp/data/models/book.dart'; import 'package:bookmarkedapp/providers/books_provider.dart'; import 'package:bookmarkedapp/providers/library_provider.dart'; import 'package:bookmarkedapp/widgets/book_cover.dart'; +import 'package:bookmarkedapp/widgets/book_info_sections.dart'; import 'package:bookmarkedapp/screens/search/isbn_scan_screen.dart'; import 'package:bookmarkedapp/widgets/search_bar.dart'; import 'package:flutter/material.dart'; @@ -201,6 +202,17 @@ class _SearchResultTile extends StatelessWidget { book.authorsDisplay, style: Theme.of(context).textTheme.bodySmall, ), + const SizedBox(height: 8), + BookMetadataChips(book: book), + if (book.plainDescription != null) ...[ + const SizedBox(height: 8), + Text( + book.plainDescription!, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], if (book.isbn != null) ...[ const SizedBox(height: 4), Text( diff --git a/lib/widgets/book_info_sections.dart b/lib/widgets/book_info_sections.dart new file mode 100644 index 0000000..0ac3232 --- /dev/null +++ b/lib/widgets/book_info_sections.dart @@ -0,0 +1,313 @@ +import 'package:bookmarkedapp/core/theme/app_theme.dart'; +import 'package:bookmarkedapp/data/models/book.dart'; +import 'package:bookmarkedapp/data/models/library_entry.dart'; +import 'package:bookmarkedapp/widgets/progress_bar.dart'; +import 'package:flutter/material.dart'; + +class BookLibraryStatusHeader extends StatelessWidget { + const BookLibraryStatusHeader({ + super.key, + required this.libraryBook, + required this.onStatusTap, + }); + + final LibraryBook libraryBook; + final VoidCallback onStatusTap; + + @override + Widget build(BuildContext context) { + final entry = libraryBook.entry; + final progress = libraryBook.displayProgress; + final book = libraryBook.book; + + return Column( + children: [ + Material( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radius), + child: InkWell( + onTap: onStatusTap, + borderRadius: BorderRadius.circular(AppTheme.radius), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + _statusIcon(entry.status), + size: 20, + color: AppTheme.textPrimary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.status.label, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontSize: 15, + ), + ), + if (progress > 0) + Text( + entry.currentPage != null && book.pageCount != null + ? 'Page ${entry.currentPage} of ${book.pageCount}' + : '${progress.round()}% complete', + style: Theme.of(context).textTheme.bodySmall, + ) + else + Text( + 'Added ${_formatDate(entry.addedAt)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: AppTheme.textSecondary, + ), + ], + ), + ), + ), + ), + if (progress > 0) ...[ + const SizedBox(height: 12), + ReadingProgressBar(progress: progress), + ], + ], + ); + } + + IconData _statusIcon(ReadingStatus status) { + switch (status) { + case ReadingStatus.wantToRead: + return Icons.bookmark_outline; + case ReadingStatus.currentlyReading: + return Icons.auto_stories_outlined; + case ReadingStatus.finished: + return Icons.check_circle_outline; + } + } + + String _formatDate(DateTime date) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; + } +} + +class BookDetailsBadgeStrip extends StatelessWidget { + const BookDetailsBadgeStrip({super.key, required this.book}); + + final Book book; + + @override + Widget build(BuildContext context) { + final items = <_DetailBadge>[ + if (book.publishedYear != null) + _DetailBadge( + icon: Icons.calendar_month_outlined, + value: '${book.publishedYear}', + ), + if (book.pageCount != null) + _DetailBadge( + icon: Icons.menu_book_outlined, + value: '${book.pageCount} pages', + ), + if (book.genres.isNotEmpty) + _DetailBadge( + icon: Icons.category_outlined, + value: book.genresDisplay, + ), + if (book.publisher != null) + _DetailBadge( + icon: Icons.business_outlined, + value: book.publisher!, + ), + if (book.isbn != null) + _DetailBadge( + icon: Icons.tag_outlined, + value: book.isbn!, + ), + ]; + + if (items.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, index) { + final item = items[index]; + return Container( + constraints: const BoxConstraints(minWidth: 88, maxWidth: 140), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + size: 20, + color: AppTheme.textPrimary, + ), + const SizedBox(height: 6), + Text( + item.value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), + ); + } +} + +class _DetailBadge { + const _DetailBadge({required this.icon, required this.value}); + + final IconData icon; + final String value; +} + +class BookDescriptionSection extends StatefulWidget { + const BookDescriptionSection({ + super.key, + required this.book, + this.isLoading = false, + }); + + final Book book; + final bool isLoading; + + @override + State createState() => _BookDescriptionSectionState(); +} + +class _BookDescriptionSectionState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final description = widget.book.plainDescription; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Description', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + if (widget.isLoading && description == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator( + minHeight: 2, + color: Colors.black, + backgroundColor: AppTheme.progressTrack, + ), + ) + else if (description != null) ...[ + Text( + description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textPrimary, + height: 1.6, + ), + maxLines: _expanded ? null : 6, + overflow: _expanded ? null : TextOverflow.ellipsis, + ), + if (description.length > 280) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => setState(() => _expanded = !_expanded), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(_expanded ? 'Show less' : 'Read more'), + ), + ), + ] else + Text( + 'No description available for this book.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ], + ); + } +} + +class BookMetadataChips extends StatelessWidget { + const BookMetadataChips({super.key, required this.book}); + + final Book book; + + @override + Widget build(BuildContext context) { + final chips = [ + if (book.publishedYear != null) '${book.publishedYear}', + if (book.genres.isNotEmpty) book.genres.first, + if (book.pageCount != null) '${book.pageCount} pages', + ]; + + if (chips.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: chips + .map( + (label) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + .toList(), + ); + } +} diff --git a/lib/widgets/book_list_tile.dart b/lib/widgets/book_list_tile.dart index 84635fe..cdb8bfc 100644 --- a/lib/widgets/book_list_tile.dart +++ b/lib/widgets/book_list_tile.dart @@ -10,58 +10,124 @@ class BookListTile extends StatelessWidget { required this.libraryBook, this.onTap, this.showProgress = true, + this.useCardStyle = false, }); final LibraryBook libraryBook; final VoidCallback? onTap; final bool showProgress; + final bool useCardStyle; @override Widget build(BuildContext context) { final book = libraryBook.book; + final progress = libraryBook.displayProgress.round(); - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppTheme.radius), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BookCover( - coverUrl: book.coverUrl, - width: 56, - height: 80, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - book.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, + final content = Padding( + padding: EdgeInsets.all(useCardStyle ? 16 : 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BookCover( + coverUrl: book.coverUrl, + width: useCardStyle ? 52 : 56, + height: useCardStyle ? 76 : 80, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + book.authorsDisplay, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (showProgress) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ReadingProgressBar( + progress: libraryBook.displayProgress, + ), + ), + const SizedBox(width: 10), + _ProgressBadge(percent: progress), + ], ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( - book.authorsDisplay, - style: Theme.of(context).textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, + libraryBook.progressPageLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), ), - if (showProgress) ...[ - const SizedBox(height: 12), - ReadingProgressBar( - progress: libraryBook.displayProgress, - ), - ], ], - ), + ], ), - ], + ), + ], + ), + ); + + if (useCardStyle) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Material( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radius), + child: content, + ), ), + ); + } + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: content, + ), + ), + ); + } +} + +class _ProgressBadge extends StatelessWidget { + const _ProgressBadge({required this.percent}); + + final int percent; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: percent > 0 ? AppTheme.textPrimary : AppTheme.progressTrack, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$percent%', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: percent > 0 ? Colors.white : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), ), ); } diff --git a/lib/widgets/remove_book_sheet.dart b/lib/widgets/remove_book_sheet.dart new file mode 100644 index 0000000..e43706e --- /dev/null +++ b/lib/widgets/remove_book_sheet.dart @@ -0,0 +1,87 @@ +import 'package:bookmarkedapp/widgets/primary_button.dart'; +import 'package:flutter/material.dart'; + +class RemoveBookSheet extends StatefulWidget { + const RemoveBookSheet({ + super.key, + required this.bookTitle, + required this.onRemove, + }); + + final String bookTitle; + final Future Function() onRemove; + + static Future show( + BuildContext context, { + required String bookTitle, + required Future Function() onRemove, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) => RemoveBookSheet( + bookTitle: bookTitle, + onRemove: onRemove, + ), + ); + } + + @override + State createState() => _RemoveBookSheetState(); +} + +class _RemoveBookSheetState extends State { + bool _isLoading = false; + + Future _handleRemove() async { + setState(() => _isLoading = true); + try { + await widget.onRemove(); + if (mounted) Navigator.of(context).pop(); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Remove from library?', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + '"${widget.bookTitle}" will be removed from your library. ' + 'You can add it back anytime.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + PrimaryButton( + label: 'Remove', + isLoading: _isLoading, + onPressed: _handleRemove, + ), + const SizedBox(height: 8), + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ), + ); + } +}