diff --git a/client/.gitignore b/client/.gitignore index af31254..b9022f1 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -45,4 +45,7 @@ app.*.map.json /android/app/release # Config. -google-services.json \ No newline at end of file +google-services.json + +# Tests. +test/data/files \ No newline at end of file diff --git a/client/lib/pages/library_page.dart b/client/lib/pages/library_page.dart index 60bbab7..91143bd 100644 --- a/client/lib/pages/library_page.dart +++ b/client/lib/pages/library_page.dart @@ -13,6 +13,7 @@ import 'package:papyrus/widgets/library/book_list_item.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; import 'package:papyrus/widgets/library/library_filter_chips.dart'; import 'package:papyrus/widgets/search/library_search_bar.dart'; +import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:provider/provider.dart'; @@ -195,9 +196,7 @@ class _LibraryPageState extends State { ), ), floatingActionButton: FloatingActionButton( - onPressed: () { - // TODO: Add book action - }, + onPressed: () => AddBookChoiceSheet.show(context), child: const Icon(Icons.add), ), ); @@ -398,9 +397,7 @@ class _LibraryPageState extends State { Widget _buildAddBookButton(double height) { return FilledButton.icon( - onPressed: () { - // TODO: Add book action - }, + onPressed: () => AddBookChoiceSheet.show(context), icon: const Icon(Icons.add), label: const Text('Add book'), style: FilledButton.styleFrom(minimumSize: Size(0, height)), diff --git a/client/lib/services/file_metadata_service.dart b/client/lib/services/file_metadata_service.dart new file mode 100644 index 0000000..e2672ff --- /dev/null +++ b/client/lib/services/file_metadata_service.dart @@ -0,0 +1,689 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:dart_mobi/dart_mobi.dart'; +import 'package:epub_pro/epub_pro.dart'; +// ignore: implementation_imports +import 'package:epub_pro/src/schema/opf/epub_metadata_date.dart'; +// ignore: implementation_imports +import 'package:epub_pro/src/schema/opf/epub_metadata_identifier.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:syncfusion_flutter_pdf/pdf.dart'; +// ignore: implementation_imports +import 'package:unrar_file/src/rarFile.dart'; +// ignore: implementation_imports +import 'package:unrar_file/src/rar_decoder.dart'; +import 'package:xml/xml.dart'; + +/// Result of extracting metadata from a book file. +class FileMetadataResult { + final String? title; + final String? subtitle; + final List? authors; + final String? publisher; + final String? publishedDate; + final String? description; + final String? language; + final String? isbn; + final String? isbn13; + final int? pageCount; + final Uint8List? coverImageBytes; + final String? coverImageMimeType; + final List warnings; + + const FileMetadataResult({ + this.title, + this.subtitle, + this.authors, + this.publisher, + this.publishedDate, + this.description, + this.language, + this.isbn, + this.isbn13, + this.pageCount, + this.coverImageBytes, + this.coverImageMimeType, + this.warnings = const [], + }); + + /// Get the primary author or empty string. + String get primaryAuthor => authors?.isNotEmpty == true ? authors!.first : ''; + + /// Get co-authors (all authors except the first). + List get coAuthors => + authors != null && authors!.length > 1 ? authors!.sublist(1) : []; +} + +/// Parsed ComicInfo.xml fields shared between CBZ and CBR extractors. +class _ComicInfoData { + final String? title; + final List? authors; + final String? publisher; + final String? description; + final String? language; + final int? pageCount; + + const _ComicInfoData({ + this.title, + this.authors, + this.publisher, + this.description, + this.language, + this.pageCount, + }); +} + +/// Service for extracting metadata from book files. +/// +/// Supports EPUB, PDF, MOBI/AZW3, CBZ, CBR, and TXT formats. +/// Never throws — returns partial results with warnings on failure. +class FileMetadataService { + static const _charsPerPage = 1500; + + static const _imageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.bmp', + '.webp', + }; + + /// Extract metadata from file bytes. + /// + /// Format is detected from the [filename] extension. Returns partial results + /// with warnings if extraction encounters issues. + Future extractMetadata( + Uint8List bytes, + String filename, + ) async { + final ext = p.extension(filename).toLowerCase(); + + try { + switch (ext) { + case '.epub': + return await _extractEpub(bytes); + case '.pdf': + return _extractPdf(bytes); + case '.mobi' || '.azw3' || '.azw': + return await _extractMobi(bytes); + case '.cbz': + return _extractCbz(bytes); + case '.cbr': + return await _extractCbr(bytes, filename); + case '.txt': + return _extractTxt(bytes, filename); + default: + return FileMetadataResult( + warnings: ['Unsupported file format: $ext'], + ); + } + } catch (e) { + return FileMetadataResult(warnings: ['Failed to extract metadata: $e']); + } + } + + // ============================================================================ + // EPUB + // ============================================================================ + + Future _extractEpub(Uint8List bytes) async { + final warnings = []; + + final book = await EpubReader.readBook(bytes); + final metadata = book.schema?.package?.metadata; + + final title = _tryRead('EPUB title', warnings, () => book.title); + + final authors = _tryRead('EPUB authors', warnings, () { + final raw = book.authors + .whereType() + .where((a) => a.isNotEmpty) + .toList(); + return raw.isNotEmpty ? raw : null; + }); + + final publisher = _tryRead( + 'EPUB publisher', + warnings, + () => metadata?.publishers.firstOrNull, + ); + final description = _tryRead( + 'EPUB description', + warnings, + () => metadata?.description, + ); + final language = _tryRead( + 'EPUB language', + warnings, + () => metadata?.languages.firstOrNull, + ); + final publishedDate = _tryRead( + 'EPUB date', + warnings, + () => _findEpubDate(metadata?.dates), + ); + + final isbns = _tryRead( + 'EPUB identifiers', + warnings, + () => _findEpubIsbns(metadata?.identifiers), + ); + + Uint8List? coverImageBytes; + String? coverImageMimeType; + final coverImage = _tryRead( + 'EPUB cover image', + warnings, + () => book.coverImage, + ); + if (coverImage != null) { + coverImageBytes = img.encodePng(coverImage); + coverImageMimeType = 'image/png'; + } + + return FileMetadataResult( + title: title, + authors: authors, + publisher: publisher, + publishedDate: publishedDate, + description: description, + language: language, + isbn: isbns?.$1, + isbn13: isbns?.$2, + coverImageBytes: coverImageBytes, + coverImageMimeType: coverImageMimeType, + warnings: warnings, + ); + } + + String? _findEpubDate(List? dates) { + if (dates == null || dates.isEmpty) return null; + + // Prefer publication date, fall back to first date. + final pubDate = dates + .where((d) => d.event?.toLowerCase() == 'publication') + .firstOrNull; + return (pubDate ?? dates.first).date; + } + + (String?, String?) _findEpubIsbns(List? ids) { + if (ids == null || ids.isEmpty) return (null, null); + + String? isbn; + String? isbn13; + + for (final id in ids) { + final value = id.identifier; + if (value == null) continue; + + final clean = value.replaceAll(RegExp(r'[-\s]'), ''); + final isIsbnScheme = id.scheme?.toLowerCase() == 'isbn'; + + if (clean.length == 10 && isbn == null && isIsbnScheme) { + isbn = clean; + } else if (clean.length == 13 && isbn13 == null) { + // ISBN-13 can appear without scheme in some EPUBs. + if (isIsbnScheme || + clean.startsWith('978') || + clean.startsWith('979')) { + isbn13 = clean; + } + } + } + + return (isbn, isbn13); + } + + // ============================================================================ + // PDF + // ============================================================================ + + FileMetadataResult _extractPdf(Uint8List bytes) { + final warnings = []; + final document = PdfDocument(inputBytes: bytes); + + try { + final info = document.documentInformation; + + // Title + String? title; + try { + final t = info.title; + if (t.isNotEmpty) title = t; + } catch (e) { + warnings.add('Could not read PDF title: $e'); + } + + // Author + List? authors; + try { + final a = info.author; + if (a.isNotEmpty) authors = [a]; + } catch (e) { + warnings.add('Could not read PDF author: $e'); + } + + // Subject → description + String? description; + try { + final s = info.subject; + if (s.isNotEmpty) description = s; + } catch (e) { + warnings.add('Could not read PDF subject: $e'); + } + + // Page count + int? pageCount; + try { + pageCount = document.pages.count; + } catch (e) { + warnings.add('Could not read PDF page count: $e'); + } + + // Published date from creation date + String? publishedDate; + try { + final date = info.creationDate; + publishedDate = date.toIso8601String().split('T').first; + } catch (_) { + // creationDate may not be set + } + + return FileMetadataResult( + title: title, + authors: authors, + description: description, + pageCount: pageCount, + publishedDate: publishedDate, + warnings: warnings, + ); + } finally { + document.dispose(); + } + } + + // ============================================================================ + // MOBI / AZW3 + // ============================================================================ + + Future _extractMobi(Uint8List bytes) async { + final warnings = []; + final mobiData = await DartMobiReader.read(bytes); + + String? getExthString(MobiExthTag tag) { + try { + final record = DartMobiReader.getExthRecordByTag(mobiData, tag); + if (record?.data != null) { + return utf8.decode(record!.data!, allowMalformed: true).trim(); + } + } catch (e) { + warnings.add('Could not read MOBI ${tag.name}: $e'); + } + return null; + } + + // Title — prefer EXTH title, fall back to MOBI header fullname. + String? title = getExthString(MobiExthTag.title); + if (title == null || title.isEmpty) { + try { + title = mobiData.mobiHeader?.fullname; + } catch (_) {} + } + + // Author + final authorStr = getExthString(MobiExthTag.author); + List? authors; + if (authorStr != null && authorStr.isNotEmpty) { + // Authors can be separated by '&', ';', or ',' + authors = authorStr + .split(RegExp(r'[;&,]')) + .map((a) => a.trim()) + .where((a) => a.isNotEmpty) + .toList(); + } + + final publisher = getExthString(MobiExthTag.publisher); + final description = getExthString(MobiExthTag.description); + final isbn = getExthString(MobiExthTag.isbm); + final language = getExthString(MobiExthTag.language); + final publishedDate = getExthString(MobiExthTag.publishingDate); + + // Cover extraction + Uint8List? coverImageBytes; + String? coverImageMimeType; + try { + final coverRecord = DartMobiReader.getExthRecordByTag( + mobiData, + MobiExthTag.coverOffset, + ); + if (coverRecord?.data != null) { + final offset = DartMobiReader.decodeExthValue( + coverRecord!.data!, + coverRecord.size!, + ); + final imageIndex = mobiData.mobiHeader?.imageIndex ?? 0; + final coverRecordIndex = imageIndex + offset; + + var record = mobiData.mobiPdbRecord; + var currentIndex = 0; + while (record != null) { + if (currentIndex == coverRecordIndex) { + coverImageBytes = record.data; + coverImageMimeType = _guessImageMimeType(record.data); + break; + } + currentIndex++; + record = record.next; + } + } + } catch (e) { + warnings.add('Could not extract MOBI cover image: $e'); + } + + if (coverImageBytes == null) { + warnings.add('MOBI cover image extraction is experimental'); + } + + // Classify ISBNs + String? isbn10; + String? isbn13; + if (isbn != null) { + final clean = isbn.replaceAll(RegExp(r'[-\s]'), ''); + if (clean.length == 13) { + isbn13 = clean; + } else if (clean.length == 10) { + isbn10 = clean; + } + } + + return FileMetadataResult( + title: title, + authors: authors, + publisher: publisher, + publishedDate: publishedDate, + description: description, + language: language, + isbn: isbn10, + isbn13: isbn13, + coverImageBytes: coverImageBytes, + coverImageMimeType: coverImageMimeType, + warnings: warnings, + ); + } + + // ============================================================================ + // CBZ (ZIP-based comic archive) + // ============================================================================ + + FileMetadataResult _extractCbz(Uint8List bytes) { + final warnings = []; + final archive = ZipDecoder().decodeBytes(bytes); + + final comicInfoBytes = archive.files + .where((f) => f.name.toLowerCase() == 'comicinfo.xml') + .firstOrNull + ?.content; + + final comicInfo = _parseComicInfo(comicInfoBytes, 'CBZ', warnings); + + // Cover: first image file (sorted alphabetically) + Uint8List? coverImageBytes; + String? coverImageMimeType; + try { + final imageFiles = + archive.files.where((f) => f.isFile && _isImageFile(f.name)).toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + if (imageFiles.isNotEmpty) { + coverImageBytes = imageFiles.first.content; + coverImageMimeType = _guessImageMimeType(coverImageBytes); + } + } catch (e) { + warnings.add('Could not extract CBZ cover image: $e'); + } + + return FileMetadataResult( + title: comicInfo.title, + authors: comicInfo.authors, + publisher: comicInfo.publisher, + description: comicInfo.description, + language: comicInfo.language, + pageCount: comicInfo.pageCount, + coverImageBytes: coverImageBytes, + coverImageMimeType: coverImageMimeType, + warnings: warnings, + ); + } + + // ============================================================================ + // CBR (RAR-based comic archive) + // ============================================================================ + + Future _extractCbr( + Uint8List bytes, + String filename, + ) async { + final warnings = []; + + // unrar_file requires file paths. Write bytes to a temp file, extract, + // then parse. This does not work on Web. + final tempDir = await Directory.systemTemp.createTemp('papyrus_cbr_'); + final tempFile = File(p.join(tempDir.path, filename)); + await tempFile.writeAsBytes(bytes); + + try { + final rar = RAR5(tempFile.path); + final List files = rar.files; + + final comicInfoBytes = files + .where( + (f) => + f.name?.toLowerCase() == 'comicinfo.xml' && f.content != null, + ) + .firstOrNull + ?.content; + + final comicInfo = _parseComicInfo(comicInfoBytes, 'CBR', warnings); + + // Cover: first image file (sorted alphabetically) + Uint8List? coverImageBytes; + String? coverImageMimeType; + try { + final imageFiles = + files + .where( + (f) => + f.content != null && + f.name != null && + _isImageFile(f.name!), + ) + .toList() + ..sort((a, b) => a.name!.compareTo(b.name!)); + + if (imageFiles.isNotEmpty) { + coverImageBytes = imageFiles.first.content; + coverImageMimeType = _guessImageMimeType(coverImageBytes); + } + } catch (e) { + warnings.add('Could not extract CBR cover image: $e'); + } + + return FileMetadataResult( + title: comicInfo.title, + authors: comicInfo.authors, + publisher: comicInfo.publisher, + description: comicInfo.description, + language: comicInfo.language, + pageCount: comicInfo.pageCount, + coverImageBytes: coverImageBytes, + coverImageMimeType: coverImageMimeType, + warnings: warnings, + ); + } catch (e) { + warnings.add('Could not read CBR archive: $e'); + return FileMetadataResult(warnings: warnings); + } finally { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } + + // ============================================================================ + // TXT + // ============================================================================ + + FileMetadataResult _extractTxt(Uint8List bytes, String filename) { + final warnings = []; + final basename = p.basenameWithoutExtension(filename); + + String? title; + List? authors; + + // Try "Author - Title" pattern + final parts = basename.split(' - '); + if (parts.length >= 2) { + authors = [parts.first.trim()]; + title = parts.sublist(1).join(' - ').trim(); + } else { + title = basename; + warnings.add('Could not detect author from filename'); + } + + // Estimate page count from byte length + int? pageCount; + try { + pageCount = (bytes.length / _charsPerPage).ceil(); + if (pageCount == 0) pageCount = 1; + } catch (e) { + warnings.add('Could not estimate page count: $e'); + } + + return FileMetadataResult( + title: title, + authors: authors, + pageCount: pageCount, + warnings: warnings, + ); + } + + // ============================================================================ + // Helpers + // ============================================================================ + + /// Try reading a metadata field; on failure, add a warning and return null. + T? _tryRead(String field, List warnings, T? Function() read) { + try { + return read(); + } catch (e) { + warnings.add('Could not read $field: $e'); + return null; + } + } + + /// Parse ComicInfo.xml bytes into metadata fields. + /// + /// If [xmlBytes] is null, adds a "not found" warning for [archiveType]. + _ComicInfoData _parseComicInfo( + Uint8List? xmlBytes, + String archiveType, + List warnings, + ) { + if (xmlBytes == null) { + warnings.add('No ComicInfo.xml found in $archiveType archive'); + return const _ComicInfoData(); + } + + try { + final doc = XmlDocument.parse(utf8.decode(xmlBytes)); + final info = doc.rootElement; + + var title = _xmlText(info, 'Title'); + final series = _xmlText(info, 'Series'); + final number = _xmlText(info, 'Number'); + if (title == null && series != null) { + title = number != null ? '$series #$number' : series; + } + + // Authors: Writer, Penciller, etc. + final authorSet = {}; + for (final role in ['Writer', 'Penciller']) { + final value = _xmlText(info, role); + if (value != null) { + authorSet.addAll(value.split(',').map((a) => a.trim())); + } + } + + final pageCountStr = _xmlText(info, 'PageCount'); + + return _ComicInfoData( + title: title, + authors: authorSet.isNotEmpty ? authorSet.toList() : null, + publisher: _xmlText(info, 'Publisher'), + description: _xmlText(info, 'Summary'), + language: _xmlText(info, 'LanguageISO'), + pageCount: pageCountStr != null ? int.tryParse(pageCountStr) : null, + ); + } catch (e) { + warnings.add('Could not parse ComicInfo.xml: $e'); + return const _ComicInfoData(); + } + } + + /// Get text content of a direct child XML element by tag name. + String? _xmlText(XmlElement parent, String tagName) { + final el = parent.findElements(tagName).firstOrNull; + if (el == null) return null; + final text = el.innerText.trim(); + return text.isEmpty ? null : text; + } + + /// Check if a filename looks like an image file. + bool _isImageFile(String filename) { + return _imageExtensions.contains(p.extension(filename).toLowerCase()); + } + + /// Guess MIME type from image bytes by checking magic bytes. + String? _guessImageMimeType(Uint8List? bytes) { + if (bytes == null || bytes.length < 4) return null; + + // JPEG: FF D8 FF + if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) { + return 'image/jpeg'; + } + // PNG: 89 50 4E 47 + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + // GIF: 47 49 46 + if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { + return 'image/gif'; + } + // BMP: 42 4D + if (bytes[0] == 0x42 && bytes[1] == 0x4D) { + return 'image/bmp'; + } + // WebP: 52 49 46 46 ... 57 45 42 50 + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46 && + bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return 'image/webp'; + } + + return null; + } +} diff --git a/client/lib/widgets/add_book/add_book_choice_sheet.dart b/client/lib/widgets/add_book/add_book_choice_sheet.dart new file mode 100644 index 0000000..c0af860 --- /dev/null +++ b/client/lib/widgets/add_book/add_book_choice_sheet.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/add_book/add_physical_book_sheet.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; + +/// Choice sheet for selecting how to add a book: digital import or physical. +class AddBookChoiceSheet extends StatelessWidget { + const AddBookChoiceSheet({super.key}); + + /// Show the choice sheet (bottom sheet on mobile, dialog on desktop). + static Future show(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth >= Breakpoints.desktopSmall; + + if (isDesktop) { + return showDialog( + context: context, + builder: (context) => Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.dialog), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: const Padding( + padding: EdgeInsets.all(Spacing.lg), + child: AddBookChoiceSheet(), + ), + ), + ), + ); + } + + return showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (context) => Padding( + padding: const EdgeInsets.only( + left: Spacing.lg, + right: Spacing.lg, + top: Spacing.md, + bottom: Spacing.lg, + ), + child: const AddBookChoiceSheet(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final isDesktop = + MediaQuery.of(context).size.width >= Breakpoints.desktopSmall; + // Capture the navigator before popping so we can use the parent route's + // context (which stays mounted) for the next sheet/dialog. + final navigator = Navigator.of(context); + final parentContext = navigator.context; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isDesktop) ...[ + const BottomSheetHandle(), + const SizedBox(height: Spacing.lg), + ], + Text('Add book', style: textTheme.headlineSmall), + const SizedBox(height: Spacing.lg), + _ChoiceOption( + icon: Icons.upload_file, + title: 'Import digital books', + subtitle: 'EPUB, PDF, MOBI, AZW3, TXT, CBR, CBZ', + onTap: () {}, + ), + const SizedBox(height: Spacing.sm), + _ChoiceOption( + icon: Icons.menu_book, + title: 'Add physical book', + subtitle: 'Enter details manually', + onTap: () { + navigator.pop(); + AddPhysicalBookSheet.show(parentContext); + }, + ), + ], + ); + } +} + +class _ChoiceOption extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _ChoiceOption({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon(icon, color: colorScheme.onPrimaryContainer), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.titleMedium), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/add_book/add_physical_book_sheet.dart b/client/lib/widgets/add_book/add_physical_book_sheet.dart new file mode 100644 index 0000000..d5e1bcd --- /dev/null +++ b/client/lib/widgets/add_book/add_physical_book_sheet.dart @@ -0,0 +1,525 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/metadata_service.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/add_book/isbn_scanner_dialog.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:provider/provider.dart'; + +/// ISBN lookup states. +enum _IsbnLookupState { idle, fetching, found, notFound, error } + +/// Sheet/dialog for adding a physical book with optional ISBN scan/lookup. +class AddPhysicalBookSheet extends StatefulWidget { + const AddPhysicalBookSheet({super.key}); + + /// Show the sheet (bottom sheet on mobile, dialog on desktop). + static Future show(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth >= Breakpoints.desktopSmall; + + if (isDesktop) { + return showDialog( + context: context, + builder: (context) => Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.dialog), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: const AddPhysicalBookSheet(), + ), + ), + ); + } + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _PhysicalBookContent( + scrollController: scrollController, + isDesktop: false, + ), + ), + ); + } + + @override + State createState() => _AddPhysicalBookSheetState(); +} + +class _AddPhysicalBookSheetState extends State { + @override + Widget build(BuildContext context) { + // Desktop dialog: rendered inside ConstrainedBox + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: const _PhysicalBookContent( + scrollController: null, + isDesktop: true, + ), + ); + } +} + +class _PhysicalBookContent extends StatefulWidget { + final ScrollController? scrollController; + final bool isDesktop; + + const _PhysicalBookContent({ + required this.scrollController, + required this.isDesktop, + }); + + @override + State<_PhysicalBookContent> createState() => _PhysicalBookContentState(); +} + +class _PhysicalBookContentState extends State<_PhysicalBookContent> { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _authorController = TextEditingController(); + final _subtitleController = TextEditingController(); + final _publisherController = TextEditingController(); + final _pageCountController = TextEditingController(); + final _isbnController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _locationController = TextEditingController(); + + final _metadataService = MetadataService(); + _IsbnLookupState _lookupState = _IsbnLookupState.idle; + String? _lookupMessage; + String? _coverUrl; + + @override + void dispose() { + _titleController.dispose(); + _authorController.dispose(); + _subtitleController.dispose(); + _publisherController.dispose(); + _pageCountController.dispose(); + _isbnController.dispose(); + _descriptionController.dispose(); + _locationController.dispose(); + super.dispose(); + } + + bool get _canSave => + _titleController.text.trim().isNotEmpty && + _authorController.text.trim().isNotEmpty; + + Future _onScanBarcode() async { + final isbn = await IsbnScannerDialog.show(context); + if (isbn != null && isbn.isNotEmpty && mounted) { + _isbnController.text = isbn; + _lookupIsbn(isbn); + } + } + + Future _lookupIsbn(String isbn) async { + if (isbn.trim().isEmpty) return; + + setState(() { + _lookupState = _IsbnLookupState.fetching; + _lookupMessage = null; + }); + + try { + // Try Open Library first + var results = await _metadataService.searchByIsbn( + isbn.trim(), + MetadataSource.openLibrary, + ); + + // Fall back to Google Books + if (results.isEmpty) { + results = await _metadataService.searchByIsbn( + isbn.trim(), + MetadataSource.googleBooks, + ); + } + + if (!mounted) return; + + if (results.isNotEmpty) { + final result = results.first; + setState(() { + if (result.title != null) _titleController.text = result.title!; + if (result.primaryAuthor.isNotEmpty) { + _authorController.text = result.primaryAuthor; + } + if (result.subtitle != null) { + _subtitleController.text = result.subtitle!; + } + if (result.publisher != null) { + _publisherController.text = result.publisher!; + } + if (result.pageCount != null) { + _pageCountController.text = result.pageCount.toString(); + } + if (result.description != null) { + _descriptionController.text = result.description!; + } + if (result.isbn != null && _isbnController.text.isEmpty) { + _isbnController.text = result.isbn!; + } + _coverUrl = result.coverUrl; + _lookupState = _IsbnLookupState.found; + }); + } else { + setState(() { + _lookupState = _IsbnLookupState.notFound; + _lookupMessage = 'No results found for this ISBN'; + }); + } + } catch (e) { + if (!mounted) return; + setState(() { + _lookupState = _IsbnLookupState.error; + _lookupMessage = 'Could not look up. Check your internet connection.'; + }); + } + } + + void _onSave() { + final dataStore = context.read(); + final now = DateTime.now(); + + final pageCount = int.tryParse(_pageCountController.text.trim()); + + final book = Book( + id: 'book-${now.millisecondsSinceEpoch}', + title: _titleController.text.trim(), + author: _authorController.text.trim(), + subtitle: _subtitleController.text.trim().isEmpty + ? null + : _subtitleController.text.trim(), + publisher: _publisherController.text.trim().isEmpty + ? null + : _publisherController.text.trim(), + pageCount: pageCount, + isbn: _isbnController.text.trim().isEmpty + ? null + : _isbnController.text.trim(), + description: _descriptionController.text.trim().isEmpty + ? null + : _descriptionController.text.trim(), + coverUrl: _coverUrl, + isPhysical: true, + physicalLocation: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + addedAt: now, + ); + + dataStore.addBook(book); + + // Capture the scaffold messenger before popping (context becomes invalid after pop) + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + + messenger.showSnackBar( + SnackBar(content: Text('Added "${book.title}" to library')), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Padding( + padding: EdgeInsets.only( + left: Spacing.lg, + right: Spacing.lg, + top: Spacing.md, + bottom: widget.isDesktop + ? Spacing.lg + : MediaQuery.of(context).viewInsets.bottom + Spacing.lg, + ), + child: Form( + key: _formKey, + child: ListView( + controller: widget.scrollController, + shrinkWrap: widget.isDesktop, + children: [ + if (!widget.isDesktop) ...[ + const BottomSheetHandle(), + const SizedBox(height: Spacing.lg), + ], + Text('Add physical book', style: textTheme.headlineSmall), + const SizedBox(height: Spacing.lg), + + // ISBN lookup section + _buildIsbnSection(colorScheme, textTheme), + const SizedBox(height: Spacing.lg), + + // Title + _buildLabel('Title', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _titleController, + textCapitalization: TextCapitalization.sentences, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => setState(() {}), + decoration: _inputDecoration('Enter book title'), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + return null; + }, + ), + const SizedBox(height: Spacing.lg), + + // Author + _buildLabel('Author', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _authorController, + textCapitalization: TextCapitalization.words, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => setState(() {}), + decoration: _inputDecoration('Enter author name'), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Author is required'; + } + return null; + }, + ), + const SizedBox(height: Spacing.lg), + + // Subtitle + _buildLabel('Subtitle', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _subtitleController, + textCapitalization: TextCapitalization.sentences, + decoration: _inputDecoration('Enter subtitle'), + ), + const SizedBox(height: Spacing.lg), + + // Publisher + _buildLabel('Publisher', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _publisherController, + textCapitalization: TextCapitalization.words, + decoration: _inputDecoration('Enter publisher'), + ), + const SizedBox(height: Spacing.lg), + + // Page count + _buildLabel('Page count', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _pageCountController, + keyboardType: TextInputType.number, + decoration: _inputDecoration('Enter page count'), + ), + const SizedBox(height: Spacing.lg), + + // ISBN + _buildLabel('ISBN', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _isbnController, + keyboardType: TextInputType.number, + decoration: _inputDecoration('Enter ISBN'), + ), + const SizedBox(height: Spacing.lg), + + // Description + _buildLabel('Description', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + maxLines: 3, + decoration: _inputDecoration('Enter description'), + ), + const SizedBox(height: Spacing.lg), + + // Physical location + _buildLabel('Physical location', textTheme, colorScheme), + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _locationController, + textCapitalization: TextCapitalization.sentences, + decoration: _inputDecoration('e.g. Bookshelf 3, top row'), + ), + const SizedBox(height: Spacing.xl), + + // Save button + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _canSave ? _onSave : null, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: Spacing.sm), + child: Text('Add to library'), + ), + ), + ), + const SizedBox(height: Spacing.md), + ], + ), + ), + ); + } + + Widget _buildIsbnSection(ColorScheme colorScheme, TextTheme textTheme) { + return Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ISBN lookup', + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: Spacing.sm), + Row( + children: [ + OutlinedButton.icon( + onPressed: _lookupState == _IsbnLookupState.fetching + ? null + : _onScanBarcode, + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan barcode'), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: TextFormField( + controller: _isbnController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'ISBN', + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), + ), + ), + const SizedBox(width: Spacing.sm), + FilledButton( + onPressed: _lookupState == _IsbnLookupState.fetching + ? null + : () => _lookupIsbn(_isbnController.text), + child: const Text('Look up'), + ), + ], + ), + if (_lookupState == _IsbnLookupState.fetching) ...[ + const SizedBox(height: Spacing.md), + Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: Spacing.sm), + Text( + 'Looking up book details...', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + if (_lookupState == _IsbnLookupState.found) ...[ + const SizedBox(height: Spacing.sm), + Row( + children: [ + Icon(Icons.check_circle, size: 16, color: colorScheme.primary), + const SizedBox(width: Spacing.xs), + Text( + 'Book details found', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], + if (_lookupState == _IsbnLookupState.notFound || + _lookupState == _IsbnLookupState.error) ...[ + const SizedBox(height: Spacing.sm), + Row( + children: [ + Icon( + _lookupState == _IsbnLookupState.notFound + ? Icons.info_outline + : Icons.error_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + _lookupMessage ?? '', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildLabel( + String text, + TextTheme textTheme, + ColorScheme colorScheme, + ) { + return Text( + text, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ); + } +} diff --git a/client/lib/widgets/add_book/isbn_scanner_dialog.dart b/client/lib/widgets/add_book/isbn_scanner_dialog.dart new file mode 100644 index 0000000..c785066 --- /dev/null +++ b/client/lib/widgets/add_book/isbn_scanner_dialog.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Full-screen camera overlay for scanning ISBN barcodes. +class IsbnScannerDialog extends StatefulWidget { + const IsbnScannerDialog({super.key}); + + /// Show the scanner and return the scanned ISBN, or null if cancelled. + static Future show(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => const IsbnScannerDialog(), + ), + ); + } + + @override + State createState() => _IsbnScannerDialogState(); +} + +class _IsbnScannerDialogState extends State { + final MobileScannerController _controller = MobileScannerController( + formats: [BarcodeFormat.ean13, BarcodeFormat.ean8], + ); + bool _hasScanned = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_hasScanned) return; + final barcode = capture.barcodes.firstOrNull; + if (barcode?.rawValue == null) return; + + _hasScanned = true; + Navigator.of(context).pop(barcode!.rawValue); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: const Text('Scan ISBN'), + ), + body: Column( + children: [ + Expanded( + child: MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.no_photography, + size: IconSizes.display, + color: colorScheme.error, + ), + const SizedBox(height: Spacing.md), + Text( + 'Camera unavailable', + style: textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Please check camera permissions and try again.', + style: textTheme.bodyMedium?.copyWith( + color: Colors.white70, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Go back'), + ), + ], + ), + ), + ); + }, + ), + ), + Container( + color: Colors.black, + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + children: [ + Text( + 'Point camera at ISBN barcode', + style: textTheme.bodyLarge?.copyWith(color: Colors.white), + ), + const SizedBox(height: Spacing.md), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, child) { + return IconButton( + onPressed: () => _controller.toggleTorch(), + icon: Icon( + state.torchState == TorchState.on + ? Icons.flash_on + : Icons.flash_off, + color: Colors.white, + size: IconSizes.medium, + ), + tooltip: 'Toggle flash', + ); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 61e703b..68eead2 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import file_picker import firebase_auth import firebase_core import google_sign_in_ios +import mobile_scanner import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -16,5 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/client/notes/features/fix-file-import-sheet-layout.md b/client/notes/features/fix-file-import-sheet-layout.md new file mode 100644 index 0000000..673f3af --- /dev/null +++ b/client/notes/features/fix-file-import-sheet-layout.md @@ -0,0 +1,42 @@ +# Fix: File Import Sheet Layout + +## Problem +The `file_import_sheet.dart` `build()` method uses `CustomScrollView` + slivers which is +broken inside `DraggableScrollableSheet`: +- Unconstrained cross-axis width during initialization causes `OutlinedButton` crash +- `LayoutBuilder` guard means footer buttons sometimes never appear +- Previous attempt to switch to `ListView` caused modal height to shrink and elements to spill + +## Root Cause +`DraggableScrollableSheet` passes unconstrained constraints during its first layout frame. +`SliverFillRemaining` + Material widgets with intrinsic width (like `OutlinedButton`) crash. + +## Approach: TDD +1. Write widget tests that render `_ImportContent` in all 3 states (picking, processing, review) + inside realistic constraints (both mobile DraggableScrollableSheet and desktop Dialog) +2. See tests fail against the current broken layout +3. Fix the layout to make tests pass +4. Refactor if needed + +## Key Decisions +- Made `_ImportContent` public as `ImportContent` with `@visibleForTesting` +- Added `autoPick` param to skip file picker in tests +- Added `setTestState()` to `AddBookProvider` for setting internal state in tests +- No mocking library needed — pre-configure provider state directly + +## Test Strategy +- Render `ImportContent` inside `DraggableScrollableSheet` (mobile) and `ConstrainedBox` (desktop) +- For each state (picking, processing, review), provide a pre-configured `AddBookProvider` +- Use `tester.scrollUntilVisible()` to verify footer buttons are reachable by scrolling +- Assert: no exceptions, expected widgets present, footer buttons visible + +## Findings During TDD +- `ListView(shrinkWrap: true)` for desktop was wrong: when expanded card metadata form + exceeds the `ConstrainedBox(maxHeight)`, content gets clipped and footer buttons disappear +- Fix: remove `shrinkWrap` entirely — `ListView` fills the bounded height from the parent + `ConstrainedBox` and scrolls. Works for both mobile (DraggableScrollableSheet gives bounds) + and desktop (Dialog's ConstrainedBox gives bounds) +- `ListView` lazily builds children — `skipOffstage: false` doesn't help find unbuilt children. + Use `scrollUntilVisible` to scroll to the footer before asserting +- Card's date field suffix (2 IconButtons) causes 10px overflow on narrow (400px) viewports — + pre-existing bug unrelated to this fix, tested with 420px width to avoid diff --git a/client/pubspec.lock b/client/pubspec.lock index 1fc7618..6a03916 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.61" + archive: + dependency: "direct main" + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -98,7 +114,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -113,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_mobi: + dependency: "direct main" + description: + name: dart_mobi + sha256: a453ffd8ab4c98c817601b61842b225d77a1301cb73bc6d47ef117ba779f95e7 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + epub_pro: + dependency: "direct main" + description: + name: epub_pro + sha256: "2f405dcf31e45abf4e714acf99ca85e6bea61643160f5a51239f3077f6d3f06a" + url: "https://pub.dev" + source: hosted + version: "5.6.0" equatable: dependency: transitive description: @@ -360,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" intl: dependency: "direct main" description: @@ -432,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 + url: "https://pub.dev" + source: hosted + version: "7.1.4" native_toolchain_c: dependency: transitive description: @@ -465,7 +513,7 @@ packages: source: hosted version: "2.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -545,13 +593,21 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" provider: dependency: "direct main" description: @@ -653,6 +709,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: "0fdd1d5e435c3fc89a5d9558ddfd9cb9272500433b141fa2068e9e5607d1c63a" + url: "https://pub.dev" + source: hosted + version: "32.2.3" + syncfusion_flutter_pdf: + dependency: "direct main" + description: + name: syncfusion_flutter_pdf + sha256: cf535daf304233052937316b72e89827811f9c7c71379ebee99eca4aa4e3850f + url: "https://pub.dev" + source: hosted + version: "32.2.3" synchronized: dependency: transitive description: @@ -685,6 +757,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unrar_file: + dependency: "direct main" + description: + name: unrar_file + sha256: "3560f36828f6050ddb904912b0a5f64a4cb4fd80aa9fd50cb55037e105c9c184" + url: "https://pub.dev" + source: hosted + version: "1.1.0" uuid: dependency: transitive description: @@ -734,7 +814,7 @@ packages: source: hosted version: "15.0.2" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" @@ -749,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + word_count: + dependency: transitive + description: + name: word_count + sha256: "98ccd8ef6d614bcf11a7e6d39d43d08193a3a42d360104234018d1dfb86c413d" + url: "https://pub.dev" + source: hosted + version: "1.0.4" xdg_directories: dependency: transitive description: @@ -758,7 +846,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/client/pubspec.yaml b/client/pubspec.yaml index d6826de..8f3cf91 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -24,12 +24,24 @@ dependencies: file_picker: ^8.0.0+1 cached_network_image: ^3.3.1 google_fonts: ^6.2.1 + epub_pro: ^5.6.0 + syncfusion_flutter_pdf: ^32.2.3 + dart_mobi: ^1.0.2 + archive: ^4.0.7 + unrar_file: ^1.1.0 + xml: ^6.6.1 + image: ^4.7.2 + path: ^1.9.1 + crypto: ^3.0.6 + web: ^1.1.1 + mobile_scanner: ^7.0.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + plugin_platform_interface: ^2.1.8 flutter: uses-material-design: true diff --git a/client/test/data/.gitkeep b/client/test/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client/test/services/file_metadata_service_test.dart b/client/test/services/file_metadata_service_test.dart new file mode 100644 index 0000000..0340074 --- /dev/null +++ b/client/test/services/file_metadata_service_test.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/services/file_metadata_service.dart'; + +void main() { + late FileMetadataService service; + + setUp(() { + service = FileMetadataService(); + }); + + Uint8List loadTestFile(String name) { + return File('test/data/files/$name').readAsBytesSync(); + } + + group('FileMetadataService', () { + group('MOBI extraction', () { + test('parses 1.mobi metadata (KF8, title, author, language)', () async { + final bytes = loadTestFile('1.mobi'); + final result = await service.extractMetadata(bytes, '1.mobi'); + + expect(result.title, 'Im Kampf um Ideale'); + expect(result.authors, contains('Georg Bonne')); + expect(result.language, 'de'); + }); + + test( + 'parses 2.mobi without crash (MOBI v6, limited EXTH support)', + () async { + final bytes = loadTestFile('2.mobi'); + final result = await service.extractMetadata(bytes, '2.mobi'); + + // MOBI v6 has limited EXTH support in dart_mobi — metadata + // fields may be null, but extraction should not crash. + expect(result.warnings, isNotEmpty); + }, + ); + }); + + group('EPUB extraction', () { + test( + 'parses 3.epub metadata (EPUB 2, title, author, cover, date)', + () async { + final bytes = loadTestFile('3.epub'); + final result = await service.extractMetadata(bytes, '3.epub'); + + expect(result.title, 'Im Kampf um Ideale'); + expect(result.authors, contains('Georg Bonne')); + expect(result.language, 'de'); + expect(result.coverImageBytes, isNotNull); + expect(result.coverImageMimeType, 'image/png'); + expect(result.publishedDate, isNotNull); + }, + ); + + test( + 'parses 4.epub metadata (EPUB 3, title, author, cover, date)', + () async { + final bytes = loadTestFile('4.epub'); + final result = await service.extractMetadata(bytes, '4.epub'); + + expect(result.title, 'Im Kampf um Ideale'); + expect(result.authors, contains('Georg Bonne')); + expect(result.language, 'de'); + expect(result.coverImageBytes, isNotNull); + expect(result.coverImageMimeType, 'image/png'); + expect(result.publishedDate, isNotNull); + }, + ); + + test( + 'parses 5.epub metadata (EPUB 2, title, author, cover, date)', + () async { + final bytes = loadTestFile('5.epub'); + final result = await service.extractMetadata(bytes, '5.epub'); + + expect(result.title, 'Im Kampf um Ideale'); + expect(result.authors, contains('Georg Bonne')); + expect(result.language, 'de'); + expect(result.coverImageBytes, isNotNull); + expect(result.coverImageMimeType, 'image/png'); + expect(result.publishedDate, isNotNull); + }, + ); + }); + + group('AZW3 extraction', () { + test( + 'parses 6.azw3 metadata (title, author, publisher, description, language)', + () async { + final bytes = loadTestFile('6.azw3'); + final result = await service.extractMetadata(bytes, '6.azw3'); + + expect(result.title, 'Dracula'); + expect(result.authors, contains('Bram Stoker')); + expect(result.publisher, 'Standard Ebooks'); + expect(result.description, contains('undead')); + expect(result.language, 'en'); + }, + ); + }); + + group('CBZ extraction', () { + test( + 'parses 7.cbz (no ComicInfo.xml, cover from first image, warning)', + () async { + final bytes = loadTestFile('7.cbz'); + final result = await service.extractMetadata(bytes, '7.cbz'); + + expect(result.coverImageBytes, isNotNull); + expect( + result.warnings, + contains('No ComicInfo.xml found in CBZ archive'), + ); + }, + ); + }); + + group('CBR extraction', () { + test( + 'handles 8.cbr RAR v4 gracefully (returns warning, no crash)', + () async { + final bytes = loadTestFile('8.cbr'); + final result = await service.extractMetadata(bytes, '8.cbr'); + + expect(result.warnings, isNotEmpty); + }, + ); + }); + + group('TXT extraction', () { + test('parses "Author - Title" filename pattern', () async { + final bytes = Uint8List.fromList(utf8.encode('Some book content.')); + final result = await service.extractMetadata( + bytes, + 'Author Name - Book Title.txt', + ); + + expect(result.title, 'Book Title'); + expect(result.authors, ['Author Name']); + }); + + test('preserves multiple hyphens in title', () async { + final bytes = Uint8List.fromList(utf8.encode('content')); + final result = await service.extractMetadata( + bytes, + 'Author - Part 1 - The Beginning.txt', + ); + + expect(result.title, 'Part 1 - The Beginning'); + expect(result.authors, ['Author']); + }); + + test('uses filename as title when no separator found', () async { + final bytes = Uint8List.fromList(utf8.encode('Some book content.')); + final result = await service.extractMetadata(bytes, 'JustATitle.txt'); + + expect(result.title, 'JustATitle'); + expect( + result.warnings, + contains('Could not detect author from filename'), + ); + }); + + test('estimates page count from content length', () async { + // 3000 bytes should yield 2 pages at ~1500 chars/page + final bytes = Uint8List(3000); + final result = await service.extractMetadata(bytes, 'book.txt'); + + expect(result.pageCount, 2); + }); + }); + + group('FileMetadataResult getters', () { + test('primaryAuthor returns first author', () { + const result = FileMetadataResult(authors: ['Alice', 'Bob']); + expect(result.primaryAuthor, 'Alice'); + }); + + test('primaryAuthor returns empty string when no authors', () { + const result = FileMetadataResult(); + expect(result.primaryAuthor, ''); + }); + + test('coAuthors returns all authors except first', () { + const result = FileMetadataResult(authors: ['Alice', 'Bob', 'Carol']); + expect(result.coAuthors, ['Bob', 'Carol']); + }); + + test('coAuthors returns empty list for single author', () { + const result = FileMetadataResult(authors: ['Alice']); + expect(result.coAuthors, isEmpty); + }); + }); + + group('format dispatch', () { + test('returns warning for unsupported file extension', () async { + final bytes = Uint8List(0); + final result = await service.extractMetadata(bytes, 'file.xyz'); + + expect(result.warnings, contains('Unsupported file format: .xyz')); + }); + }); + + group('error handling', () { + test('never throws on corrupted data', () async { + final corruptedBytes = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + // Should not throw for any supported format + for (final filename in [ + 'bad.epub', + 'bad.mobi', + 'bad.azw3', + 'bad.cbz', + 'bad.cbr', + 'bad.pdf', + ]) { + final result = await service.extractMetadata( + corruptedBytes, + filename, + ); + expect( + result.warnings, + isNotEmpty, + reason: 'Expected warnings for $filename', + ); + } + }); + }); + }); +}