From 12caa646d5b166700afc39189e6f2ce0fc9aad11 Mon Sep 17 00:00:00 2001 From: KillCode2301 Date: Wed, 1 Jul 2026 04:25:19 +0500 Subject: [PATCH 1/4] Show book descriptions, genres, and publication year on detail screens Extend the book model and local database with genre, publisher, and published year fields. Fetch full descriptions from linked Open Library works and Google Books, enrich metadata on the detail screen, and surface description, genre, year, and related details in search results and the book detail view. --- lib/data/database/app_database.dart | 16 +- lib/data/database/app_database.g.dart | 226 +++++++++++++- lib/data/models/book.dart | 101 +++++++ lib/data/repositories/library_repository.dart | 72 +++-- lib/data/services/book_search_service.dart | 73 ++++- .../book_detail/book_detail_screen.dart | 160 +++++++--- lib/screens/search/search_screen.dart | 12 + lib/widgets/book_info_sections.dart | 285 ++++++++++++++++++ 8 files changed, 852 insertions(+), 93 deletions(-) create mode 100644 lib/widgets/book_info_sections.dart 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/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..7f689d9 100644 --- a/lib/screens/book_detail/book_detail_screen.dart +++ b/lib/screens/book_detail/book_detail_screen.dart @@ -3,12 +3,15 @@ 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/status_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +enum _BookMenuAction { changeStatus, remove } + class BookDetailScreen extends ConsumerStatefulWidget { const BookDetailScreen({super.key, required this.bookId}); @@ -19,36 +22,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 +77,54 @@ class _BookDetailScreenState extends ConsumerState { await ref.read(libraryRepositoryProvider).updateRating(bookId, rating); } + Future _confirmRemove(LibraryBook libraryBook) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove from library?'), + content: Text( + '"${libraryBook.book.title}" will be removed from your library. ' + 'You can add it back anytime.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text( + 'Remove', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + 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(); + } + + void _handleMenuAction(_BookMenuAction action, LibraryBook libraryBook) { + switch (action) { + case _BookMenuAction.changeStatus: + _showStatusSheet(libraryBook); + case _BookMenuAction.remove: + _confirmRemove(libraryBook); + } + } + @override Widget build(BuildContext context) { final entryAsync = ref.watch(libraryEntryProvider(widget.bookId)); @@ -76,9 +139,23 @@ class _BookDetailScreenState extends ConsumerState { ), actions: [ if (entryAsync.valueOrNull != null) - IconButton( + PopupMenuButton<_BookMenuAction>( icon: const Icon(Icons.more_horiz), - onPressed: () => _showStatusSheet(entryAsync.value!), + onSelected: (action) => + _handleMenuAction(action, entryAsync.value!), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _BookMenuAction.changeStatus, + child: Text('Change status'), + ), + PopupMenuItem( + value: _BookMenuAction.remove, + child: Text( + 'Remove from library', + style: TextStyle(color: Colors.red), + ), + ), + ], ), ], ), @@ -86,12 +163,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')); } @@ -123,6 +205,8 @@ class _BookDetailScreenState extends ConsumerState { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), + const SizedBox(height: 12), + BookMetadataChips(book: book), if (inLibrary) ...[ const SizedBox(height: 16), _RatingRow( @@ -130,32 +214,16 @@ class _BookDetailScreenState extends ConsumerState { 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: 24), + BookDetailsCard( + book: book, + libraryBook: libraryBook, + ), + const SizedBox(height: 24), + BookDescriptionSection( + book: book, + isLoading: _loadingDetails, + ), const SizedBox(height: 32), PrimaryButton( label: inLibrary ? 'Update Progress' : 'Add to Library', @@ -216,12 +284,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/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..38d5180 --- /dev/null +++ b/lib/widgets/book_info_sections.dart @@ -0,0 +1,285 @@ +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 BookDetailsCard extends StatelessWidget { + const BookDetailsCard({ + super.key, + required this.book, + this.libraryBook, + }); + + final Book book; + final LibraryBook? libraryBook; + + @override + Widget build(BuildContext context) { + final entry = libraryBook?.entry; + final progress = libraryBook?.displayProgress ?? 0; + final hasDetails = book.publishedYear != null || + book.genres.isNotEmpty || + book.publisher != null || + book.pageCount != null || + book.isbn != null || + entry != null; + + if (!hasDetails) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(20), + decoration: AppTheme.cardDecoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Details', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + if (entry != null) ...[ + BookDetailRow( + icon: Icons.bookmark_outline, + label: 'Status', + value: entry.status.label, + ), + const SizedBox(height: 12), + ], + if (book.publishedYear != null) ...[ + BookDetailRow( + icon: Icons.calendar_month_outlined, + label: 'Published', + value: '${book.publishedYear}', + ), + const SizedBox(height: 12), + ], + if (book.genres.isNotEmpty) ...[ + BookDetailRow( + icon: Icons.category_outlined, + label: 'Genre', + value: book.genresDisplay, + ), + const SizedBox(height: 12), + ], + if (book.publisher != null) ...[ + BookDetailRow( + icon: Icons.business_outlined, + label: 'Publisher', + value: book.publisher!, + ), + const SizedBox(height: 12), + ], + if (book.pageCount != null) ...[ + BookDetailRow( + icon: Icons.menu_book_outlined, + label: 'Pages', + value: '${book.pageCount}', + ), + const SizedBox(height: 12), + ], + if (book.isbn != null) ...[ + BookDetailRow( + icon: Icons.tag_outlined, + label: 'ISBN', + value: book.isbn!, + ), + const SizedBox(height: 12), + ], + if (entry != null && progress > 0) ...[ + BookDetailRow( + icon: Icons.trending_up, + label: 'Progress', + value: entry.currentPage != null && book.pageCount != null + ? 'Page ${entry.currentPage} of ${book.pageCount}' + : '${progress.round()}%', + ), + const SizedBox(height: 8), + ReadingProgressBar(progress: progress), + const SizedBox(height: 12), + ], + if (entry != null) + BookDetailRow( + icon: Icons.event_outlined, + label: 'Added', + value: _formatDate(entry.addedAt), + ), + ], + ), + ); + } + + 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 BookDetailRow extends StatelessWidget { + const BookDetailRow({ + super.key, + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: AppTheme.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 2), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ); + } +} + +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(), + ); + } +} From 94111a7c749e64b8ba9ea0b17462cb3a9d928aa9 Mon Sep 17 00:00:00 2001 From: KillCode2301 Date: Wed, 1 Jul 2026 04:30:32 +0500 Subject: [PATCH 2/4] Redesign book detail layout with icon grid and status header Move library stats to a tappable status header below the title, show book metadata in a 2-column icon grid, open change status from the header tap, and use a remove bottom sheet from the 3-dot menu. --- .../book_detail/book_detail_screen.dart | 106 ++----- lib/widgets/book_info_sections.dart | 289 ++++++++++-------- lib/widgets/remove_book_sheet.dart | 87 ++++++ 3 files changed, 272 insertions(+), 210 deletions(-) create mode 100644 lib/widgets/remove_book_sheet.dart diff --git a/lib/screens/book_detail/book_detail_screen.dart b/lib/screens/book_detail/book_detail_screen.dart index 7f689d9..e58c779 100644 --- a/lib/screens/book_detail/book_detail_screen.dart +++ b/lib/screens/book_detail/book_detail_screen.dart @@ -6,12 +6,11 @@ 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'; -enum _BookMenuAction { changeStatus, remove } - class BookDetailScreen extends ConsumerStatefulWidget { const BookDetailScreen({super.key, required this.bookId}); @@ -77,52 +76,21 @@ class _BookDetailScreenState extends ConsumerState { await ref.read(libraryRepositoryProvider).updateRating(bookId, rating); } - Future _confirmRemove(LibraryBook libraryBook) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove from library?'), - content: Text( - '"${libraryBook.book.title}" will be removed from your library. ' - 'You can add it back anytime.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text( - 'Remove', - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - await ref - .read(libraryRepositoryProvider) - .removeFromLibrary(libraryBook.book.id); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Removed from library')), + 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(); + }, ); - Navigator.of(context).pop(); - } - - void _handleMenuAction(_BookMenuAction action, LibraryBook libraryBook) { - switch (action) { - case _BookMenuAction.changeStatus: - _showStatusSheet(libraryBook); - case _BookMenuAction.remove: - _confirmRemove(libraryBook); - } } @override @@ -139,23 +107,9 @@ class _BookDetailScreenState extends ConsumerState { ), actions: [ if (entryAsync.valueOrNull != null) - PopupMenuButton<_BookMenuAction>( + IconButton( icon: const Icon(Icons.more_horiz), - onSelected: (action) => - _handleMenuAction(action, entryAsync.value!), - itemBuilder: (context) => const [ - PopupMenuItem( - value: _BookMenuAction.changeStatus, - child: Text('Change status'), - ), - PopupMenuItem( - value: _BookMenuAction.remove, - child: Text( - 'Remove from library', - style: TextStyle(color: Colors.red), - ), - ), - ], + onPressed: () => _showRemoveSheet(entryAsync.value!), ), ], ), @@ -205,9 +159,12 @@ class _BookDetailScreenState extends ConsumerState { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), - const SizedBox(height: 12), - BookMetadataChips(book: book), if (inLibrary) ...[ + const SizedBox(height: 16), + BookLibraryStatusHeader( + libraryBook: libraryBook, + onStatusTap: () => _showStatusSheet(libraryBook), + ), const SizedBox(height: 16), _RatingRow( rating: rating, @@ -215,10 +172,7 @@ class _BookDetailScreenState extends ConsumerState { ), ], const SizedBox(height: 24), - BookDetailsCard( - book: book, - libraryBook: libraryBook, - ), + BookDetailsGrid(book: book), const SizedBox(height: 24), BookDescriptionSection( book: book, @@ -235,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'), - ), - ], ], ), ); diff --git a/lib/widgets/book_info_sections.dart b/lib/widgets/book_info_sections.dart index 38d5180..9db6026 100644 --- a/lib/widgets/book_info_sections.dart +++ b/lib/widgets/book_info_sections.dart @@ -4,111 +4,94 @@ import 'package:bookmarkedapp/data/models/library_entry.dart'; import 'package:bookmarkedapp/widgets/progress_bar.dart'; import 'package:flutter/material.dart'; -class BookDetailsCard extends StatelessWidget { - const BookDetailsCard({ +class BookLibraryStatusHeader extends StatelessWidget { + const BookLibraryStatusHeader({ super.key, - required this.book, - this.libraryBook, + required this.libraryBook, + required this.onStatusTap, }); - final Book book; - final LibraryBook? libraryBook; + final LibraryBook libraryBook; + final VoidCallback onStatusTap; @override Widget build(BuildContext context) { - final entry = libraryBook?.entry; - final progress = libraryBook?.displayProgress ?? 0; - final hasDetails = book.publishedYear != null || - book.genres.isNotEmpty || - book.publisher != null || - book.pageCount != null || - book.isbn != null || - entry != null; + final entry = libraryBook.entry; + final progress = libraryBook.displayProgress; + final book = libraryBook.book; - if (!hasDetails) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.all(20), - decoration: AppTheme.cardDecoration, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Details', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - if (entry != null) ...[ - BookDetailRow( - icon: Icons.bookmark_outline, - label: 'Status', - value: entry.status.label, - ), - const SizedBox(height: 12), - ], - if (book.publishedYear != null) ...[ - BookDetailRow( - icon: Icons.calendar_month_outlined, - label: 'Published', - value: '${book.publishedYear}', - ), - const SizedBox(height: 12), - ], - if (book.genres.isNotEmpty) ...[ - BookDetailRow( - icon: Icons.category_outlined, - label: 'Genre', - value: book.genresDisplay, - ), - const SizedBox(height: 12), - ], - if (book.publisher != null) ...[ - BookDetailRow( - icon: Icons.business_outlined, - label: 'Publisher', - value: book.publisher!, - ), - const SizedBox(height: 12), - ], - if (book.pageCount != null) ...[ - BookDetailRow( - icon: Icons.menu_book_outlined, - label: 'Pages', - value: '${book.pageCount}', - ), - const SizedBox(height: 12), - ], - if (book.isbn != null) ...[ - BookDetailRow( - icon: Icons.tag_outlined, - label: 'ISBN', - value: book.isbn!, - ), - const SizedBox(height: 12), - ], - if (entry != null && progress > 0) ...[ - BookDetailRow( - icon: Icons.trending_up, - label: 'Progress', - value: entry.currentPage != null && book.pageCount != null - ? 'Page ${entry.currentPage} of ${book.pageCount}' - : '${progress.round()}%', - ), - const SizedBox(height: 8), - ReadingProgressBar(progress: progress), - const SizedBox(height: 12), - ], - if (entry != null) - BookDetailRow( - icon: Icons.event_outlined, - label: 'Added', - value: _formatDate(entry.addedAt), + 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', @@ -128,49 +111,101 @@ class BookDetailsCard extends StatelessWidget { } } -class BookDetailRow extends StatelessWidget { - const BookDetailRow({ - super.key, - required this.icon, - required this.label, - required this.value, - }); +class BookDetailsGrid extends StatelessWidget { + const BookDetailsGrid({super.key, required this.book}); - final IconData icon; - final String label; - final String value; + final Book book; @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 18, color: AppTheme.textSecondary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 2), - Text( - value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), + final items = <_DetailGridItem>[ + if (book.publishedYear != null) + _DetailGridItem( + icon: Icons.calendar_month_outlined, + value: '${book.publishedYear}', ), - ], + if (book.pageCount != null) + _DetailGridItem( + icon: Icons.menu_book_outlined, + value: '${book.pageCount}', + ), + if (book.genres.isNotEmpty) + _DetailGridItem( + icon: Icons.category_outlined, + value: book.genresDisplay, + ), + if (book.publisher != null) + _DetailGridItem( + icon: Icons.business_outlined, + value: book.publisher!, + ), + if (book.isbn != null) + _DetailGridItem( + icon: Icons.tag_outlined, + value: book.isbn!, + ), + ]; + + if (items.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: AppTheme.cardDecoration, + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.6, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: AppTheme.background, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFEEEEEE)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + size: 22, + color: AppTheme.textPrimary, + ), + const SizedBox(height: 8), + Text( + item.value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), ); } } +class _DetailGridItem { + const _DetailGridItem({required this.icon, required this.value}); + + final IconData icon; + final String value; +} + class BookDescriptionSection extends StatefulWidget { const BookDescriptionSection({ super.key, 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'), + ), + ], + ), + ); + } +} From ab370035e6a493b4a9433e19584e613687e8e727 Mon Sep 17 00:00:00 2001 From: KillCode2301 Date: Wed, 1 Jul 2026 04:33:27 +0500 Subject: [PATCH 3/4] Replace book details grid with horizontal scrolling badge strip Show metadata as vertical icon badges in a horizontally scrollable row directly below the rating stars for a cleaner visual hierarchy. --- .../book_detail/book_detail_screen.dart | 4 +- lib/widgets/book_info_sections.dart | 53 ++++++++----------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/lib/screens/book_detail/book_detail_screen.dart b/lib/screens/book_detail/book_detail_screen.dart index e58c779..abefd17 100644 --- a/lib/screens/book_detail/book_detail_screen.dart +++ b/lib/screens/book_detail/book_detail_screen.dart @@ -171,8 +171,8 @@ class _BookDetailScreenState extends ConsumerState { onRatingChanged: (r) => _updateRating(book.id, r), ), ], - const SizedBox(height: 24), - BookDetailsGrid(book: book), + const SizedBox(height: 16), + BookDetailsBadgeStrip(book: book), const SizedBox(height: 24), BookDescriptionSection( book: book, diff --git a/lib/widgets/book_info_sections.dart b/lib/widgets/book_info_sections.dart index 9db6026..0ac3232 100644 --- a/lib/widgets/book_info_sections.dart +++ b/lib/widgets/book_info_sections.dart @@ -111,36 +111,36 @@ class BookLibraryStatusHeader extends StatelessWidget { } } -class BookDetailsGrid extends StatelessWidget { - const BookDetailsGrid({super.key, required this.book}); +class BookDetailsBadgeStrip extends StatelessWidget { + const BookDetailsBadgeStrip({super.key, required this.book}); final Book book; @override Widget build(BuildContext context) { - final items = <_DetailGridItem>[ + final items = <_DetailBadge>[ if (book.publishedYear != null) - _DetailGridItem( + _DetailBadge( icon: Icons.calendar_month_outlined, value: '${book.publishedYear}', ), if (book.pageCount != null) - _DetailGridItem( + _DetailBadge( icon: Icons.menu_book_outlined, - value: '${book.pageCount}', + value: '${book.pageCount} pages', ), if (book.genres.isNotEmpty) - _DetailGridItem( + _DetailBadge( icon: Icons.category_outlined, value: book.genresDisplay, ), if (book.publisher != null) - _DetailGridItem( + _DetailBadge( icon: Icons.business_outlined, value: book.publisher!, ), if (book.isbn != null) - _DetailGridItem( + _DetailBadge( icon: Icons.tag_outlined, value: book.isbn!, ), @@ -148,43 +148,36 @@ class BookDetailsGrid extends StatelessWidget { if (items.isEmpty) return const SizedBox.shrink(); - return Container( - padding: const EdgeInsets.all(16), - decoration: AppTheme.cardDecoration, - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.6, - ), + 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( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + constraints: const BoxConstraints(minWidth: 88, maxWidth: 140), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: AppTheme.background, + color: AppTheme.surface, borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFEEEEEE)), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( item.icon, - size: 22, + size: 20, color: AppTheme.textPrimary, ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( item.value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textPrimary, fontWeight: FontWeight.w600, - fontSize: 13, + fontSize: 12, ), textAlign: TextAlign.center, maxLines: 2, @@ -199,8 +192,8 @@ class BookDetailsGrid extends StatelessWidget { } } -class _DetailGridItem { - const _DetailGridItem({required this.icon, required this.value}); +class _DetailBadge { + const _DetailBadge({required this.icon, required this.value}); final IconData icon; final String value; From 0ff64aaf8be020bacd59f2c1d8fccc8e68aee9dc Mon Sep 17 00:00:00 2001 From: Thalhath <71560490+KillCode2301@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:55:20 +0500 Subject: [PATCH 4/4] Fix library categories with animated expand/collapse (#7) * Fix library category tap with inline expand/collapse Replace navigation to a separate status route (which lacked a Material ancestor and caused InkWell errors) with animated accordion sections that reveal books in each reading status category on the library page. * Add page and percent progress to category book tiles Show page read, a percentage badge, and progress bar on library book rows. Category books use card styling for clearer visual separation. --- lib/core/router/app_router.dart | 12 -- lib/data/models/library_entry.dart | 19 +++ lib/screens/library/library_screen.dart | 211 +++++++++++++++--------- lib/widgets/book_list_tile.dart | 138 ++++++++++++---- 4 files changed, 257 insertions(+), 123 deletions(-) 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/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/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/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, + ), ), ); }