From abb65cbbc932425a4943b96962a3a276beda0491 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 6 Feb 2026 16:55:11 +0200 Subject: [PATCH 1/4] Add missing fields to book metadata form based on the book data model anf update form layout. --- client/lib/pages/book_edit_page.dart | 99 ++++++++++++++++--- client/lib/providers/book_edit_provider.dart | 21 ++++ .../widgets/book_details/book_info_grid.dart | 59 +++++------ .../widgets/book_edit/cover_image_picker.dart | 8 +- 4 files changed, 132 insertions(+), 55 deletions(-) diff --git a/client/lib/pages/book_edit_page.dart b/client/lib/pages/book_edit_page.dart index 61f99bc..b61f2a9 100644 --- a/client/lib/pages/book_edit_page.dart +++ b/client/lib/pages/book_edit_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/providers/book_edit_provider.dart'; import 'package:papyrus/services/metadata_service.dart'; @@ -31,6 +32,7 @@ class _BookEditPageState extends State { final _isbnController = TextEditingController(); final _isbn13Controller = TextEditingController(); final _descriptionController = TextEditingController(); + final _publicationDateController = TextEditingController(); final _metadataSearchController = TextEditingController(); List _coAuthors = []; @@ -65,6 +67,9 @@ class _BookEditPageState extends State { _isbnController.text = book.isbn ?? ''; _isbn13Controller.text = book.isbn13 ?? ''; _descriptionController.text = book.description ?? ''; + _publicationDateController.text = book.publicationDate != null + ? DateFormat.yMMMMd().format(book.publicationDate!) + : ''; setState(() { _coAuthors = List.from(book.coAuthors); }); @@ -81,6 +86,7 @@ class _BookEditPageState extends State { _isbnController.dispose(); _isbn13Controller.dispose(); _descriptionController.dispose(); + _publicationDateController.dispose(); _metadataSearchController.dispose(); _provider.dispose(); super.dispose(); @@ -198,13 +204,17 @@ class _BookEditPageState extends State { width: 360, child: Column( children: [ - _buildSectionCard( - title: 'Cover', - children: [ - _buildCoverSection(context, provider, isDesktop: true), - ], + Card( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: _buildCoverSection( + context, + provider, + isDesktop: true, + ), + ), ), - const SizedBox(height: Spacing.lg), + const SizedBox(height: Spacing.sm), _buildSectionCard( title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)], @@ -265,7 +275,7 @@ class _BookEditPageState extends State { _buildCoAuthorsSection(context), ], ), - const SizedBox(height: Spacing.md), + const SizedBox(height: Spacing.sm), _buildSectionCard( title: 'Publication details', children: [ @@ -282,9 +292,14 @@ class _BookEditPageState extends State { ), ]), const SizedBox(height: Spacing.md), - SizedBox( - width: _isDesktop ? 200 : double.infinity, - child: _buildTextField( + _buildResponsiveRow([ + _buildDateField( + controller: _publicationDateController, + label: 'Publication date', + value: _provider.editedBook?.publicationDate, + onChanged: _provider.updatePublicationDate, + ), + _buildTextField( controller: _pageCountController, label: 'Page count', keyboardType: TextInputType.number, @@ -293,10 +308,10 @@ class _BookEditPageState extends State { _provider.updatePageCount(pages); }, ), - ), + ]), ], ), - const SizedBox(height: Spacing.md), + const SizedBox(height: Spacing.sm), _buildSectionCard( title: 'Identifiers', children: [ @@ -314,7 +329,7 @@ class _BookEditPageState extends State { ]), ], ), - const SizedBox(height: Spacing.md), + const SizedBox(height: Spacing.sm), _buildSectionCard( title: 'Description', children: [ @@ -327,7 +342,7 @@ class _BookEditPageState extends State { ], ), if (!skipMetadata) ...[ - const SizedBox(height: Spacing.md), + const SizedBox(height: Spacing.sm), _buildSectionCard( title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)], @@ -395,6 +410,59 @@ class _BookEditPageState extends State { ); } + Widget _buildDateField({ + required TextEditingController controller, + required String label, + required DateTime? value, + required void Function(DateTime?) onChanged, + }) { + return TextFormField( + controller: controller, + readOnly: true, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (value != null) + IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + controller.clear(); + onChanged(null); + }, + ), + IconButton( + icon: const Icon(Icons.calendar_today, size: 20), + onPressed: () => _pickDate(controller, value, onChanged), + ), + ], + ), + ), + onTap: () => _pickDate(controller, value, onChanged), + ); + } + + Future _pickDate( + TextEditingController controller, + DateTime? currentValue, + void Function(DateTime?) onChanged, + ) async { + final picked = await showDatePicker( + context: context, + initialDate: currentValue ?? DateTime.now(), + firstDate: DateTime(1000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + controller.text = DateFormat.yMMMMd().format(picked); + onChanged(picked); + } + } + Widget _buildResponsiveRow(List children) { if (!_isDesktop || children.length == 1) { return Column( @@ -716,6 +784,9 @@ class _BookEditPageState extends State { _isbnController.text = book.isbn ?? ''; _isbn13Controller.text = book.isbn13 ?? ''; _descriptionController.text = book.description ?? ''; + _publicationDateController.text = book.publicationDate != null + ? DateFormat.yMMMMd().format(book.publicationDate!) + : ''; setState(() { _coAuthors = List.from(book.coAuthors); }); diff --git a/client/lib/providers/book_edit_provider.dart b/client/lib/providers/book_edit_provider.dart index 0b3c5f3..9f0f2a3 100644 --- a/client/lib/providers/book_edit_provider.dart +++ b/client/lib/providers/book_edit_provider.dart @@ -333,10 +333,30 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); } + /// Try to parse a date string in various formats (yyyy-MM-dd, yyyy-MM, yyyy). + DateTime? _tryParseDate(String? dateStr) { + if (dateStr == null || dateStr.isEmpty) return null; + // Try full date: yyyy-MM-dd + final fullDate = DateTime.tryParse(dateStr); + if (fullDate != null) return fullDate; + // Try year-month: yyyy-MM + if (RegExp(r'^\d{4}-\d{2}$').hasMatch(dateStr)) { + return DateTime.tryParse('$dateStr-01'); + } + // Try year only: yyyy + final year = int.tryParse(dateStr); + if (year != null && year > 0 && year < 10000) { + return DateTime(year); + } + return null; + } + /// Apply fetched metadata to the edited book. void applyMetadata(BookMetadataResult result) { if (_editedBook == null) return; + final parsedDate = _tryParseDate(result.publishedDate); + _editedBook = _editedBook!.copyWith( title: result.title ?? _editedBook!.title, subtitle: result.subtitle ?? _editedBook!.subtitle, @@ -353,6 +373,7 @@ class BookEditProvider extends ChangeNotifier { isbn13: result.isbn13 ?? _editedBook!.isbn13, description: result.description ?? _editedBook!.description, coverUrl: result.coverUrl ?? _editedBook!.coverUrl, + publicationDate: parsedDate ?? _editedBook!.publicationDate, ); // Clear fetch results after applying diff --git a/client/lib/widgets/book_details/book_info_grid.dart b/client/lib/widgets/book_details/book_info_grid.dart index 7ba3124..eba3d44 100644 --- a/client/lib/widgets/book_details/book_info_grid.dart +++ b/client/lib/widgets/book_details/book_info_grid.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -47,52 +48,36 @@ class BookInfoGrid extends StatelessWidget { List<_InfoEntry> _buildEntries() { final entries = <_InfoEntry>[]; - // Format entries.add(_InfoEntry('Format', book.formatLabel)); - // Pages if (book.totalPages != null) { entries.add(_InfoEntry('Pages', '${book.totalPages}')); } - // These would come from extended BookData in the future - // For now, use placeholder data based on sample books - final sampleInfo = _getSampleInfoForBook(book.id); - entries.addAll(sampleInfo); + if (book.publisher != null && book.publisher!.isNotEmpty) { + entries.add(_InfoEntry('Publisher', book.publisher!)); + } - return entries; - } + if (book.publicationDate != null) { + entries.add( + _InfoEntry( + 'Published', + DateFormat.yMMMMd().format(book.publicationDate!), + ), + ); + } - List<_InfoEntry> _getSampleInfoForBook(String bookId) { - // Sample metadata for demo purposes - switch (bookId) { - case '1': - return [ - const _InfoEntry('Publisher', 'Addison-Wesley'), - const _InfoEntry('Published', 'October 30, 2019'), - const _InfoEntry('ISBN', '978-0135957059'), - const _InfoEntry('Language', 'English'), - ]; - case '2': - return [ - const _InfoEntry('Publisher', 'Pearson'), - const _InfoEntry('Published', 'August 1, 2008'), - const _InfoEntry('ISBN', '978-0132350884'), - const _InfoEntry('Language', 'English'), - ]; - case '3': - return [ - const _InfoEntry('Publisher', 'Addison-Wesley'), - const _InfoEntry('Published', 'October 31, 1994'), - const _InfoEntry('ISBN', '978-0201633610'), - const _InfoEntry('Language', 'English'), - ]; - default: - return [ - const _InfoEntry('Publisher', 'Unknown'), - const _InfoEntry('Language', 'English'), - ]; + if (book.isbn13 != null && book.isbn13!.isNotEmpty) { + entries.add(_InfoEntry('ISBN-13', book.isbn13!)); + } else if (book.isbn != null && book.isbn!.isNotEmpty) { + entries.add(_InfoEntry('ISBN', book.isbn!)); } + + if (book.language != null && book.language!.isNotEmpty) { + entries.add(_InfoEntry('Language', book.language!)); + } + + return entries; } } diff --git a/client/lib/widgets/book_edit/cover_image_picker.dart b/client/lib/widgets/book_edit/cover_image_picker.dart index 342bd1a..166b565 100644 --- a/client/lib/widgets/book_edit/cover_image_picker.dart +++ b/client/lib/widgets/book_edit/cover_image_picker.dart @@ -75,8 +75,8 @@ class _CoverImagePickerState extends State { super.dispose(); } - double get _coverWidth => widget.isDesktop ? 240.0 : 150.0; - double get _coverHeight => widget.isDesktop ? 360.0 : 225.0; + double get _coverWidth => widget.isDesktop ? 280.0 : 150.0; + double get _coverHeight => widget.isDesktop ? 420.0 : 225.0; // Mobile controls width for comfortable touch targets double get _mobileControlsWidth => 280.0; @@ -119,7 +119,7 @@ class _CoverImagePickerState extends State { ), ), ), - SizedBox(width: widget.isDesktop ? Spacing.xs : Spacing.md), + SizedBox(width: widget.isDesktop ? Spacing.md : Spacing.md), Expanded( child: OutlinedButton.icon( onPressed: () => @@ -142,7 +142,7 @@ class _CoverImagePickerState extends State { // URL input if (_showUrlInput) ...[ - SizedBox(height: widget.isDesktop ? Spacing.sm : Spacing.md), + const SizedBox(height: Spacing.md), _wrapWithWidth( TextField( controller: _urlController, From 3dfd1f73223fe4fa1daa0158ed372b59306a6c8f Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 6 Feb 2026 17:11:46 +0200 Subject: [PATCH 2/4] Add series name and number fields, implement rating and physical book tracking. --- client/lib/models/book.dart | 16 +- client/lib/pages/book_edit_page.dart | 141 ++++++++++++++++++ client/lib/providers/book_edit_provider.dart | 48 ++++++ .../lib/widgets/book_details/book_header.dart | 25 +++- .../widgets/book_details/book_info_grid.dart | 49 ++++++ 5 files changed, 272 insertions(+), 7 deletions(-) diff --git a/client/lib/models/book.dart b/client/lib/models/book.dart index f8aa717..6f6b1fc 100644 --- a/client/lib/models/book.dart +++ b/client/lib/models/book.dart @@ -109,7 +109,8 @@ class Book { // Series final String? seriesId; - final int? seriesNumber; + final String? seriesName; + final double? seriesNumber; // Timestamps final DateTime addedAt; @@ -147,6 +148,7 @@ class Book { this.rating, this.customMetadata, this.seriesId, + this.seriesName, this.seriesNumber, required this.addedAt, this.startedAt, @@ -226,7 +228,8 @@ class Book { int? rating, Map? customMetadata, String? seriesId, - int? seriesNumber, + String? seriesName, + double? seriesNumber, DateTime? addedAt, DateTime? startedAt, DateTime? completedAt, @@ -262,6 +265,7 @@ class Book { rating: rating ?? this.rating, customMetadata: customMetadata ?? this.customMetadata, seriesId: seriesId ?? this.seriesId, + seriesName: seriesName ?? this.seriesName, seriesNumber: seriesNumber ?? this.seriesNumber, addedAt: addedAt ?? this.addedAt, startedAt: startedAt ?? this.startedAt, @@ -285,7 +289,7 @@ class Book { 'language': language, 'page_count': pageCount, 'description': description, - 'cover_url': coverUrl, + 'cover_image_url': coverUrl, 'file_path': filePath, 'file_format': fileFormat?.name, 'file_size': fileSize, @@ -302,6 +306,7 @@ class Book { 'rating': rating, 'custom_metadata': customMetadata, 'series_id': seriesId, + 'series_name': seriesName, 'series_number': seriesNumber, 'added_at': addedAt.toIso8601String(), 'started_at': startedAt?.toIso8601String(), @@ -331,7 +336,7 @@ class Book { language: json['language'] as String?, pageCount: json['page_count'] as int?, description: json['description'] as String?, - coverUrl: json['cover_url'] as String?, + coverUrl: json['cover_image_url'] as String?, filePath: json['file_path'] as String?, fileFormat: json['file_format'] != null ? BookFormat.values.byName(json['file_format'] as String) @@ -354,7 +359,8 @@ class Book { rating: json['rating'] as int?, customMetadata: json['custom_metadata'] as Map?, seriesId: json['series_id'] as String?, - seriesNumber: json['series_number'] as int?, + seriesName: json['series_name'] as String?, + seriesNumber: (json['series_number'] as num?)?.toDouble(), addedAt: DateTime.parse(json['added_at'] as String), startedAt: json['started_at'] != null ? DateTime.parse(json['started_at'] as String) diff --git a/client/lib/pages/book_edit_page.dart b/client/lib/pages/book_edit_page.dart index b61f2a9..04c125a 100644 --- a/client/lib/pages/book_edit_page.dart +++ b/client/lib/pages/book_edit_page.dart @@ -33,6 +33,11 @@ class _BookEditPageState extends State { final _isbn13Controller = TextEditingController(); final _descriptionController = TextEditingController(); final _publicationDateController = TextEditingController(); + final _seriesNameController = TextEditingController(); + final _seriesNumberController = TextEditingController(); + final _physicalLocationController = TextEditingController(); + final _lentToController = TextEditingController(); + final _lentAtController = TextEditingController(); final _metadataSearchController = TextEditingController(); List _coAuthors = []; @@ -70,11 +75,26 @@ class _BookEditPageState extends State { _publicationDateController.text = book.publicationDate != null ? DateFormat.yMMMMd().format(book.publicationDate!) : ''; + _seriesNameController.text = book.seriesName ?? ''; + _seriesNumberController.text = book.seriesNumber != null + ? _formatSeriesNumber(book.seriesNumber!) + : ''; + _physicalLocationController.text = book.physicalLocation ?? ''; + _lentToController.text = book.lentTo ?? ''; + _lentAtController.text = book.lentAt != null + ? DateFormat.yMMMMd().format(book.lentAt!) + : ''; setState(() { _coAuthors = List.from(book.coAuthors); }); } + String _formatSeriesNumber(double number) { + return number == number.roundToDouble() + ? number.toInt().toString() + : number.toString(); + } + @override void dispose() { _titleController.dispose(); @@ -87,6 +107,11 @@ class _BookEditPageState extends State { _isbn13Controller.dispose(); _descriptionController.dispose(); _publicationDateController.dispose(); + _seriesNameController.dispose(); + _seriesNumberController.dispose(); + _physicalLocationController.dispose(); + _lentToController.dispose(); + _lentAtController.dispose(); _metadataSearchController.dispose(); _provider.dispose(); super.dispose(); @@ -273,6 +298,8 @@ class _BookEditPageState extends State { ]), const SizedBox(height: Spacing.md), _buildCoAuthorsSection(context), + const SizedBox(height: Spacing.md), + _buildRatingRow(context, provider), ], ), const SizedBox(height: Spacing.sm), @@ -341,6 +368,35 @@ class _BookEditPageState extends State { ), ], ), + const SizedBox(height: Spacing.sm), + _buildSectionCard( + title: 'Series', + children: [ + _buildResponsiveRow([ + _buildTextField( + controller: _seriesNameController, + label: 'Series name', + onChanged: _provider.updateSeriesName, + ), + _buildTextField( + controller: _seriesNumberController, + label: 'Number in series', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (value) { + final number = double.tryParse(value); + _provider.updateSeriesNumber(number); + }, + ), + ]), + ], + ), + const SizedBox(height: Spacing.sm), + _buildSectionCard( + title: 'Physical book', + children: [_buildPhysicalBookSection(context, provider)], + ), if (!skipMetadata) ...[ const SizedBox(height: Spacing.sm), _buildSectionCard( @@ -483,6 +539,91 @@ class _BookEditPageState extends State { ); } + // ============================================================================ + // RATING SECTION + // ============================================================================ + + Widget _buildRatingRow(BuildContext context, BookEditProvider provider) { + final rating = provider.editedBook?.rating ?? 0; + final colorScheme = Theme.of(context).colorScheme; + + return Row( + children: [ + Text( + 'Rating', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(width: Spacing.sm), + ...List.generate(5, (index) { + final starValue = index + 1; + final isSelected = starValue <= rating; + return GestureDetector( + onTap: () { + _provider.updateRating(starValue == rating ? null : starValue); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon( + isSelected ? Icons.star_rounded : Icons.star_outline_rounded, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + size: 28, + ), + ), + ); + }), + ], + ); + } + + // ============================================================================ + // PHYSICAL BOOK SECTION + // ============================================================================ + + Widget _buildPhysicalBookSection( + BuildContext context, + BookEditProvider provider, + ) { + final isPhysical = provider.editedBook?.isPhysical ?? false; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: const Text('Physical book'), + value: isPhysical, + onChanged: (value) => _provider.updateIsPhysical(value), + contentPadding: EdgeInsets.zero, + ), + if (isPhysical) ...[ + const SizedBox(height: Spacing.sm), + _buildTextField( + controller: _physicalLocationController, + label: 'Location', + onChanged: _provider.updatePhysicalLocation, + ), + const SizedBox(height: Spacing.md), + _buildResponsiveRow([ + _buildTextField( + controller: _lentToController, + label: 'Lent to', + onChanged: _provider.updateLentTo, + ), + _buildDateField( + controller: _lentAtController, + label: 'Lent at', + value: provider.editedBook?.lentAt, + onChanged: _provider.updateLentAt, + ), + ]), + ], + ], + ); + } + // ============================================================================ // COVER SECTION // ============================================================================ diff --git a/client/lib/providers/book_edit_provider.dart b/client/lib/providers/book_edit_provider.dart index 9f0f2a3..38c4f8a 100644 --- a/client/lib/providers/book_edit_provider.dart +++ b/client/lib/providers/book_edit_provider.dart @@ -268,6 +268,54 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); } + void updateRating(int? value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith(rating: value); + notifyListeners(); + } + + void updateSeriesName(String? value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith( + seriesName: value?.isEmpty == true ? null : value, + ); + notifyListeners(); + } + + void updateSeriesNumber(double? value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith(seriesNumber: value); + notifyListeners(); + } + + void updateIsPhysical(bool value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith(isPhysical: value); + notifyListeners(); + } + + void updatePhysicalLocation(String? value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith( + physicalLocation: value?.isEmpty == true ? null : value, + ); + notifyListeners(); + } + + void updateLentTo(String? value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith( + lentTo: value?.isEmpty == true ? null : value, + ); + notifyListeners(); + } + + void updateLentAt(DateTime? value) { + if (_editedBook == null) return; + _editedBook = _editedBook!.copyWith(lentAt: value); + notifyListeners(); + } + // ============================================================================ // METADATA FETCH // ============================================================================ diff --git a/client/lib/widgets/book_details/book_header.dart b/client/lib/widgets/book_details/book_header.dart index a22eb34..b74eb45 100644 --- a/client/lib/widgets/book_details/book_header.dart +++ b/client/lib/widgets/book_details/book_header.dart @@ -57,11 +57,21 @@ class BookHeader extends StatelessWidget { context, ).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.bold), ), + if (book.subtitle != null && book.subtitle!.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + Text( + book.subtitle!, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + ], const SizedBox(height: Spacing.xs), // Author Text( - book.author, + book.allAuthors, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -122,11 +132,22 @@ class BookHeader extends StatelessWidget { ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), + if (book.subtitle != null && book.subtitle!.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + Text( + book.subtitle!, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), + ], const SizedBox(height: Spacing.xs), // Author (centered) Text( - book.author, + book.allAuthors, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/client/lib/widgets/book_details/book_info_grid.dart b/client/lib/widgets/book_details/book_info_grid.dart index eba3d44..07bd4da 100644 --- a/client/lib/widgets/book_details/book_info_grid.dart +++ b/client/lib/widgets/book_details/book_info_grid.dart @@ -77,8 +77,57 @@ class BookInfoGrid extends StatelessWidget { entries.add(_InfoEntry('Language', book.language!)); } + // Series + if (book.seriesName != null && book.seriesName!.isNotEmpty) { + final seriesValue = book.seriesNumber != null + ? '${book.seriesName} #${_formatSeriesNumber(book.seriesNumber!)}' + : book.seriesName!; + entries.add(_InfoEntry('Series', seriesValue)); + } else if (book.seriesNumber != null) { + entries.add( + _InfoEntry('Series', '#${_formatSeriesNumber(book.seriesNumber!)}'), + ); + } + + // Rating + if (book.rating != null) { + entries.add( + _InfoEntry( + 'Rating', + '${'★' * book.rating!}${'☆' * (5 - book.rating!)}', + ), + ); + } + + // Reading status + if (book.readingStatus != ReadingStatus.notStarted) { + entries.add(_InfoEntry('Status', book.readingStatus.label)); + } + + // Physical location + if (book.isPhysical && + book.physicalLocation != null && + book.physicalLocation!.isNotEmpty) { + entries.add(_InfoEntry('Location', book.physicalLocation!)); + } + + // Lending + if (book.lentTo != null && book.lentTo!.isNotEmpty) { + final lentValue = book.lentAt != null + ? '${book.lentTo!} (since ${DateFormat.yMMMd().format(book.lentAt!)})' + : book.lentTo!; + entries.add(_InfoEntry('Lent to', lentValue)); + } + return entries; } + + /// Format series number: show as integer if whole, otherwise as decimal. + static String _formatSeriesNumber(double number) { + return number == number.roundToDouble() + ? number.toInt().toString() + : number.toString(); + } } class _InfoEntry { From a5830b9babf3a1bb93ef037b5884246469a7215e Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 6 Feb 2026 17:21:25 +0200 Subject: [PATCH 3/4] Update physical book details layout, do not show physical book info when physical book flag is set to false. --- client/lib/pages/book_edit_page.dart | 11 ++++++++--- client/lib/widgets/book_details/book_info_grid.dart | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/client/lib/pages/book_edit_page.dart b/client/lib/pages/book_edit_page.dart index 04c125a..7994011 100644 --- a/client/lib/pages/book_edit_page.dart +++ b/client/lib/pages/book_edit_page.dart @@ -393,9 +393,14 @@ class _BookEditPageState extends State { ], ), const SizedBox(height: Spacing.sm), - _buildSectionCard( - title: 'Physical book', - children: [_buildPhysicalBookSection(context, provider)], + Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: _buildPhysicalBookSection(context, provider), + ), ), if (!skipMetadata) ...[ const SizedBox(height: Spacing.sm), diff --git a/client/lib/widgets/book_details/book_info_grid.dart b/client/lib/widgets/book_details/book_info_grid.dart index 07bd4da..ec50a52 100644 --- a/client/lib/widgets/book_details/book_info_grid.dart +++ b/client/lib/widgets/book_details/book_info_grid.dart @@ -112,7 +112,7 @@ class BookInfoGrid extends StatelessWidget { } // Lending - if (book.lentTo != null && book.lentTo!.isNotEmpty) { + if (book.isPhysical && book.lentTo != null && book.lentTo!.isNotEmpty) { final lentValue = book.lentAt != null ? '${book.lentTo!} (since ${DateFormat.yMMMd().format(book.lentAt!)})' : book.lentTo!; From d831d2bc9f5c6789e451375d0f46b8aebe8928e0 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 6 Feb 2026 17:36:10 +0200 Subject: [PATCH 4/4] Make book details ad edit form app bars have a consistent style in desktop view. --- client/lib/pages/book_details_page.dart | 12 +++- client/lib/pages/book_edit_page.dart | 93 +++++++++++++++++++------ 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/client/lib/pages/book_details_page.dart b/client/lib/pages/book_details_page.dart index f557c19..d158d89 100644 --- a/client/lib/pages/book_details_page.dart +++ b/client/lib/pages/book_details_page.dart @@ -169,7 +169,7 @@ class _BookDetailsPageState extends State children: [ // Back navigation Container( - height: 48, + height: ComponentSizes.appBarHeight + 1, padding: const EdgeInsets.symmetric(horizontal: Spacing.md), decoration: BoxDecoration( border: Border( @@ -180,8 +180,14 @@ class _BookDetailsPageState extends State children: [ TextButton.icon( onPressed: () => context.go('/library/books'), - icon: const Icon(Icons.arrow_back), - label: const Text('Back to library'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurface, + ), + icon: const Icon(Icons.arrow_back, size: 20), + label: Text( + 'Library', + style: Theme.of(context).textTheme.titleMedium, + ), ), ], ), diff --git a/client/lib/pages/book_edit_page.dart b/client/lib/pages/book_edit_page.dart index 7994011..e81f38a 100644 --- a/client/lib/pages/book_edit_page.dart +++ b/client/lib/pages/book_edit_page.dart @@ -171,29 +171,29 @@ class _BookEditPageState extends State { context.pop(); } }, - child: Scaffold( - appBar: AppBar( - title: const Text('Edit book'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => _handleCancel(context), - ), - actions: [ - TextButton( - onPressed: provider.canSave - ? () => _handleSave(context) - : null, - child: const Text('Save'), + child: _isDesktop + ? _buildDesktopScaffold(context, provider) + : Scaffold( + appBar: AppBar( + title: const Text('Edit book'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => _handleCancel(context), + ), + actions: [ + TextButton( + onPressed: provider.canSave + ? () => _handleSave(context) + : null, + child: const Text('Save'), + ), + ], + ), + body: Form( + key: _formKey, + child: _buildMobileLayout(context, provider), + ), ), - ], - ), - body: Form( - key: _formKey, - child: _isDesktop - ? _buildDesktopLayout(context, provider) - : _buildMobileLayout(context, provider), - ), - ), ); }, ), @@ -204,6 +204,55 @@ class _BookEditPageState extends State { // LAYOUTS // ============================================================================ + Widget _buildDesktopScaffold( + BuildContext context, + BookEditProvider provider, + ) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + // Top bar matching book details page style + Container( + height: ComponentSizes.appBarHeight + 1, + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + TextButton.icon( + onPressed: () => _handleCancel(context), + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurface, + ), + icon: const Icon(Icons.arrow_back, size: 20), + label: Text( + 'Edit book', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const Spacer(), + FilledButton( + onPressed: provider.canSave ? () => _handleSave(context) : null, + child: const Text('Save'), + ), + ], + ), + ), + // Content + Expanded( + child: Form( + key: _formKey, + child: _buildDesktopLayout(context, provider), + ), + ), + ], + ); + } + Widget _buildMobileLayout(BuildContext context, BookEditProvider provider) { return ListView( padding: const EdgeInsets.all(Spacing.md),