From 65c838d4d82c6ca2cde15383b33a5b8f4d9d386b Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 8 Feb 2026 21:50:04 +0200 Subject: [PATCH 1/6] Implement book files metadata parser service. --- client/.gitignore | 5 +- .../lib/services/file_metadata_service.dart | 723 ++++++++++++++++++ client/pubspec.lock | 84 +- client/pubspec.yaml | 8 + client/test/data/.gitkeep | 0 5 files changed, 817 insertions(+), 3 deletions(-) create mode 100644 client/lib/services/file_metadata_service.dart create mode 100644 client/test/data/.gitkeep diff --git a/client/.gitignore b/client/.gitignore index af31254..f2ccd09 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. +client/test/data/files \ No newline at end of file diff --git a/client/lib/services/file_metadata_service.dart b/client/lib/services/file_metadata_service.dart new file mode 100644 index 0000000..0e3066b --- /dev/null +++ b/client/lib/services/file_metadata_service.dart @@ -0,0 +1,723 @@ +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) : []; +} + +/// 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 { + /// 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; + + // Title + String? title; + try { + title = book.title; + } catch (e) { + warnings.add('Could not read EPUB title: $e'); + } + + // Authors + List? authors; + try { + final rawAuthors = book.authors + .whereType() + .where((a) => a.isNotEmpty) + .toList(); + if (rawAuthors.isNotEmpty) authors = rawAuthors; + } catch (e) { + warnings.add('Could not read EPUB authors: $e'); + } + + // Publisher + String? publisher; + try { + publisher = metadata?.publishers.firstOrNull; + } catch (e) { + warnings.add('Could not read EPUB publisher: $e'); + } + + // Description + String? description; + try { + description = metadata?.description; + } catch (e) { + warnings.add('Could not read EPUB description: $e'); + } + + // Language + String? language; + try { + language = metadata?.languages.firstOrNull; + } catch (e) { + warnings.add('Could not read EPUB language: $e'); + } + + // Date + String? publishedDate; + try { + publishedDate = _findEpubDate(metadata?.dates); + } catch (e) { + warnings.add('Could not read EPUB date: $e'); + } + + // Identifiers (ISBN) + String? isbn; + String? isbn13; + try { + final result = _findEpubIsbns(metadata?.identifiers); + isbn = result.$1; + isbn13 = result.$2; + } catch (e) { + warnings.add('Could not read EPUB identifiers: $e'); + } + + // Cover image + Uint8List? coverImageBytes; + String? coverImageMimeType; + try { + if (book.coverImage != null) { + coverImageBytes = img.encodePng(book.coverImage!); + coverImageMimeType = 'image/png'; + } + } catch (e) { + warnings.add('Could not read EPUB cover image: $e'); + } + + return FileMetadataResult( + title: title, + authors: authors, + publisher: publisher, + publishedDate: publishedDate, + description: description, + language: language, + isbn: isbn, + isbn13: isbn13, + 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); + + // Look for ComicInfo.xml + String? title; + List? authors; + String? publisher; + String? description; + String? language; + int? pageCount; + + final comicInfoFile = archive.files + .where((f) => f.name.toLowerCase() == 'comicinfo.xml') + .firstOrNull; + + if (comicInfoFile != null) { + try { + final xmlString = utf8.decode(comicInfoFile.content); + final doc = XmlDocument.parse(xmlString); + final info = doc.rootElement; + + 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 writer = _xmlText(info, 'Writer'); + final penciller = _xmlText(info, 'Penciller'); + final authorSet = {}; + if (writer != null) { + authorSet.addAll(writer.split(',').map((a) => a.trim())); + } + if (penciller != null) { + authorSet.addAll(penciller.split(',').map((a) => a.trim())); + } + if (authorSet.isNotEmpty) authors = authorSet.toList(); + + publisher = _xmlText(info, 'Publisher'); + description = _xmlText(info, 'Summary'); + language = _xmlText(info, 'LanguageISO'); + + final pageCountStr = _xmlText(info, 'PageCount'); + if (pageCountStr != null) pageCount = int.tryParse(pageCountStr); + } catch (e) { + warnings.add('Could not parse ComicInfo.xml: $e'); + } + } else { + warnings.add('No ComicInfo.xml found in CBZ archive'); + } + + // 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: title, + authors: authors, + publisher: publisher, + description: description, + language: language, + pageCount: 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 { + // Use the RAR5 pure-Dart decoder directly. + final rar = RAR5(tempFile.path); + final List files = rar.files; + + // Look for ComicInfo.xml + String? title; + List? authors; + String? publisher; + String? description; + String? language; + int? pageCount; + + final comicInfoFile = files + .where( + (f) => + f.name?.toLowerCase() == 'comicinfo.xml' && f.content != null, + ) + .firstOrNull; + + if (comicInfoFile != null) { + try { + final xmlString = utf8.decode(comicInfoFile.content!); + final doc = XmlDocument.parse(xmlString); + final info = doc.rootElement; + + 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; + } + + final writer = _xmlText(info, 'Writer'); + final penciller = _xmlText(info, 'Penciller'); + final authorSet = {}; + if (writer != null) { + authorSet.addAll(writer.split(',').map((a) => a.trim())); + } + if (penciller != null) { + authorSet.addAll(penciller.split(',').map((a) => a.trim())); + } + if (authorSet.isNotEmpty) authors = authorSet.toList(); + + publisher = _xmlText(info, 'Publisher'); + description = _xmlText(info, 'Summary'); + language = _xmlText(info, 'LanguageISO'); + + final pageCountStr = _xmlText(info, 'PageCount'); + if (pageCountStr != null) pageCount = int.tryParse(pageCountStr); + } catch (e) { + warnings.add('Could not parse ComicInfo.xml: $e'); + } + } else { + warnings.add('No ComicInfo.xml found in CBR archive'); + } + + // 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: title, + authors: authors, + publisher: publisher, + description: description, + language: language, + pageCount: 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 (~1500 characters per page) + int? pageCount; + try { + final charCount = bytes.length; + pageCount = (charCount / 1500).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 + // ============================================================================ + + /// 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) { + final ext = p.extension(filename).toLowerCase(); + return const { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.bmp', + '.webp', + }.contains(ext); + } + + /// 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/pubspec.lock b/client/pubspec.lock index 1fc7618..ec7f5cb 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: @@ -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: @@ -465,7 +505,7 @@ packages: source: hosted version: "2.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -552,6 +592,14 @@ packages: 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 +701,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 +749,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: @@ -749,6 +821,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 +838,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..093437b 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -24,6 +24,14 @@ 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 dev_dependencies: flutter_test: diff --git a/client/test/data/.gitkeep b/client/test/data/.gitkeep new file mode 100644 index 0000000..e69de29 From 804350815f73ba9980290dbac390a550eb8e9c30 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 8 Feb 2026 23:07:03 +0200 Subject: [PATCH 2/6] Add tests for file metadata parser service. --- client/.gitignore | 2 +- .../lib/services/file_metadata_service.dart | 338 ++++++++---------- .../services/file_metadata_service_test.dart | 234 ++++++++++++ 3 files changed, 387 insertions(+), 187 deletions(-) create mode 100644 client/test/services/file_metadata_service_test.dart diff --git a/client/.gitignore b/client/.gitignore index f2ccd09..b9022f1 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -48,4 +48,4 @@ app.*.map.json google-services.json # Tests. -client/test/data/files \ No newline at end of file +test/data/files \ No newline at end of file diff --git a/client/lib/services/file_metadata_service.dart b/client/lib/services/file_metadata_service.dart index 0e3066b..e2672ff 100644 --- a/client/lib/services/file_metadata_service.dart +++ b/client/lib/services/file_metadata_service.dart @@ -58,11 +58,41 @@ class FileMetadataResult { 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 @@ -107,79 +137,53 @@ class FileMetadataService { final book = await EpubReader.readBook(bytes); final metadata = book.schema?.package?.metadata; - // Title - String? title; - try { - title = book.title; - } catch (e) { - warnings.add('Could not read EPUB title: $e'); - } + final title = _tryRead('EPUB title', warnings, () => book.title); - // Authors - List? authors; - try { - final rawAuthors = book.authors + final authors = _tryRead('EPUB authors', warnings, () { + final raw = book.authors .whereType() .where((a) => a.isNotEmpty) .toList(); - if (rawAuthors.isNotEmpty) authors = rawAuthors; - } catch (e) { - warnings.add('Could not read EPUB authors: $e'); - } + return raw.isNotEmpty ? raw : null; + }); - // Publisher - String? publisher; - try { - publisher = metadata?.publishers.firstOrNull; - } catch (e) { - warnings.add('Could not read EPUB publisher: $e'); - } - - // Description - String? description; - try { - description = metadata?.description; - } catch (e) { - warnings.add('Could not read EPUB description: $e'); - } - - // Language - String? language; - try { - language = metadata?.languages.firstOrNull; - } catch (e) { - warnings.add('Could not read EPUB language: $e'); - } - - // Date - String? publishedDate; - try { - publishedDate = _findEpubDate(metadata?.dates); - } catch (e) { - warnings.add('Could not read EPUB date: $e'); - } + 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), + ); - // Identifiers (ISBN) - String? isbn; - String? isbn13; - try { - final result = _findEpubIsbns(metadata?.identifiers); - isbn = result.$1; - isbn13 = result.$2; - } catch (e) { - warnings.add('Could not read EPUB identifiers: $e'); - } + final isbns = _tryRead( + 'EPUB identifiers', + warnings, + () => _findEpubIsbns(metadata?.identifiers), + ); - // Cover image Uint8List? coverImageBytes; String? coverImageMimeType; - try { - if (book.coverImage != null) { - coverImageBytes = img.encodePng(book.coverImage!); - coverImageMimeType = 'image/png'; - } - } catch (e) { - warnings.add('Could not read EPUB cover image: $e'); + final coverImage = _tryRead( + 'EPUB cover image', + warnings, + () => book.coverImage, + ); + if (coverImage != null) { + coverImageBytes = img.encodePng(coverImage); + coverImageMimeType = 'image/png'; } return FileMetadataResult( @@ -189,8 +193,8 @@ class FileMetadataService { publishedDate: publishedDate, description: description, language: language, - isbn: isbn, - isbn13: isbn13, + isbn: isbns?.$1, + isbn13: isbns?.$2, coverImageBytes: coverImageBytes, coverImageMimeType: coverImageMimeType, warnings: warnings, @@ -420,56 +424,12 @@ class FileMetadataService { final warnings = []; final archive = ZipDecoder().decodeBytes(bytes); - // Look for ComicInfo.xml - String? title; - List? authors; - String? publisher; - String? description; - String? language; - int? pageCount; - - final comicInfoFile = archive.files + final comicInfoBytes = archive.files .where((f) => f.name.toLowerCase() == 'comicinfo.xml') - .firstOrNull; + .firstOrNull + ?.content; - if (comicInfoFile != null) { - try { - final xmlString = utf8.decode(comicInfoFile.content); - final doc = XmlDocument.parse(xmlString); - final info = doc.rootElement; - - 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 writer = _xmlText(info, 'Writer'); - final penciller = _xmlText(info, 'Penciller'); - final authorSet = {}; - if (writer != null) { - authorSet.addAll(writer.split(',').map((a) => a.trim())); - } - if (penciller != null) { - authorSet.addAll(penciller.split(',').map((a) => a.trim())); - } - if (authorSet.isNotEmpty) authors = authorSet.toList(); - - publisher = _xmlText(info, 'Publisher'); - description = _xmlText(info, 'Summary'); - language = _xmlText(info, 'LanguageISO'); - - final pageCountStr = _xmlText(info, 'PageCount'); - if (pageCountStr != null) pageCount = int.tryParse(pageCountStr); - } catch (e) { - warnings.add('Could not parse ComicInfo.xml: $e'); - } - } else { - warnings.add('No ComicInfo.xml found in CBZ archive'); - } + final comicInfo = _parseComicInfo(comicInfoBytes, 'CBZ', warnings); // Cover: first image file (sorted alphabetically) Uint8List? coverImageBytes; @@ -488,12 +448,12 @@ class FileMetadataService { } return FileMetadataResult( - title: title, - authors: authors, - publisher: publisher, - description: description, - language: language, - pageCount: pageCount, + title: comicInfo.title, + authors: comicInfo.authors, + publisher: comicInfo.publisher, + description: comicInfo.description, + language: comicInfo.language, + pageCount: comicInfo.pageCount, coverImageBytes: coverImageBytes, coverImageMimeType: coverImageMimeType, warnings: warnings, @@ -517,62 +477,18 @@ class FileMetadataService { await tempFile.writeAsBytes(bytes); try { - // Use the RAR5 pure-Dart decoder directly. final rar = RAR5(tempFile.path); final List files = rar.files; - // Look for ComicInfo.xml - String? title; - List? authors; - String? publisher; - String? description; - String? language; - int? pageCount; - - final comicInfoFile = files + final comicInfoBytes = files .where( (f) => f.name?.toLowerCase() == 'comicinfo.xml' && f.content != null, ) - .firstOrNull; - - if (comicInfoFile != null) { - try { - final xmlString = utf8.decode(comicInfoFile.content!); - final doc = XmlDocument.parse(xmlString); - final info = doc.rootElement; - - 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; - } - - final writer = _xmlText(info, 'Writer'); - final penciller = _xmlText(info, 'Penciller'); - final authorSet = {}; - if (writer != null) { - authorSet.addAll(writer.split(',').map((a) => a.trim())); - } - if (penciller != null) { - authorSet.addAll(penciller.split(',').map((a) => a.trim())); - } - if (authorSet.isNotEmpty) authors = authorSet.toList(); + .firstOrNull + ?.content; - publisher = _xmlText(info, 'Publisher'); - description = _xmlText(info, 'Summary'); - language = _xmlText(info, 'LanguageISO'); - - final pageCountStr = _xmlText(info, 'PageCount'); - if (pageCountStr != null) pageCount = int.tryParse(pageCountStr); - } catch (e) { - warnings.add('Could not parse ComicInfo.xml: $e'); - } - } else { - warnings.add('No ComicInfo.xml found in CBR archive'); - } + final comicInfo = _parseComicInfo(comicInfoBytes, 'CBR', warnings); // Cover: first image file (sorted alphabetically) Uint8List? coverImageBytes; @@ -598,12 +514,12 @@ class FileMetadataService { } return FileMetadataResult( - title: title, - authors: authors, - publisher: publisher, - description: description, - language: language, - pageCount: pageCount, + title: comicInfo.title, + authors: comicInfo.authors, + publisher: comicInfo.publisher, + description: comicInfo.description, + language: comicInfo.language, + pageCount: comicInfo.pageCount, coverImageBytes: coverImageBytes, coverImageMimeType: coverImageMimeType, warnings: warnings, @@ -639,11 +555,10 @@ class FileMetadataService { warnings.add('Could not detect author from filename'); } - // Estimate page count (~1500 characters per page) + // Estimate page count from byte length int? pageCount; try { - final charCount = bytes.length; - pageCount = (charCount / 1500).ceil(); + pageCount = (bytes.length / _charsPerPage).ceil(); if (pageCount == 0) pageCount = 1; } catch (e) { warnings.add('Could not estimate page count: $e'); @@ -661,6 +576,65 @@ class FileMetadataService { // 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; @@ -671,15 +645,7 @@ class FileMetadataService { /// Check if a filename looks like an image file. bool _isImageFile(String filename) { - final ext = p.extension(filename).toLowerCase(); - return const { - '.jpg', - '.jpeg', - '.png', - '.gif', - '.bmp', - '.webp', - }.contains(ext); + return _imageExtensions.contains(p.extension(filename).toLowerCase()); } /// Guess MIME type from image bytes by checking magic bytes. 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', + ); + } + }); + }); + }); +} From 14771aab30d9bce94ee9bb98bb8c42a36c612870 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 9 Feb 2026 03:18:46 +0200 Subject: [PATCH 3/6] Fix file import sheet layout and add widget tests. Replace CustomScrollView+slivers with plain ListView in ImportContent. The slivers approach caused infinite-width crashes inside DraggableScrollableSheet and footer buttons sometimes never appeared due to a LayoutBuilder guard. The ListView approach scrolls naturally in both mobile (DraggableScrollableSheet) and desktop (Dialog) contexts. Add 7 widget tests covering all 3 import phases (picking, processing, review) on both mobile and desktop layouts, verifying no rendering exceptions and that footer buttons are reachable by scrolling. --- client/lib/providers/add_book_provider.dart | 481 ++++++++++++++++++ .../widgets/add_book/file_import_sheet.dart | 398 +++++++++++++++ .../features/fix-file-import-sheet-layout.md | 42 ++ .../add_book/file_import_sheet_test.dart | 338 ++++++++++++ 4 files changed, 1259 insertions(+) create mode 100644 client/lib/providers/add_book_provider.dart create mode 100644 client/lib/widgets/add_book/file_import_sheet.dart create mode 100644 client/notes/features/fix-file-import-sheet-layout.md create mode 100644 client/test/widgets/add_book/file_import_sheet_test.dart diff --git a/client/lib/providers/add_book_provider.dart b/client/lib/providers/add_book_provider.dart new file mode 100644 index 0000000..488a07c --- /dev/null +++ b/client/lib/providers/add_book_provider.dart @@ -0,0 +1,481 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/file_metadata_service.dart'; +import 'package:papyrus/services/sha256_hash.dart'; +import 'package:path/path.dart' as p; + +/// Wait until the next frame is painted and the browser has composited. +/// +/// After the post-frame callback fires, a short [Future.delayed] exits the +/// requestAnimationFrame callback so the browser can actually composite pixels +/// to screen before execution continues. Without this, the next synchronous +/// work starts before any visual update is displayed. +Future _ensureFramePainted() async { + final completer = Completer(); + SchedulerBinding.instance.addPostFrameCallback((_) { + completer.complete(); + }); + SchedulerBinding.instance.scheduleFrame(); + await completer.future; + // Exit rAF so browser can composite. 16ms ≈ one frame at 60fps. + await Future.delayed(const Duration(milliseconds: 16)); +} + +/// Combined result of hashing and metadata extraction from an isolate. +class _FileProcessResult { + final String fileHash; + final FileMetadataResult metadata; + const _FileProcessResult({required this.fileHash, required this.metadata}); +} + +/// Top-level entry point for compute(). Runs SHA-256 hashing and metadata +/// extraction in a single call — off the main thread on native platforms. +Future<_FileProcessResult> _processFileInIsolate( + (Uint8List, String) args, +) async { + final (bytes, filename) = args; + final hash = sha256.convert(bytes).toString(); + final metadata = await FileMetadataService().extractMetadata(bytes, filename); + return _FileProcessResult(fileHash: hash, metadata: metadata); +} + +/// Status of a single file import. +enum FileImportStatus { pending, extracting, success, duplicate, error } + +/// A single file being imported. +class FileImportItem { + final String fileName; + final int fileSize; + final Uint8List bytes; + final BookFormat format; + FileImportStatus status; + String? errorMessage; + FileMetadataResult? metadata; + Uint8List? coverImageBytes; + String? coverImageMimeType; + String? fileHash; + + // Editable fields (pre-filled from metadata) + String title; + String author; + String? subtitle; + String? publisher; + String? language; + String? isbn; + String? isbn13; + String? description; + int? pageCount; + List coAuthors; + String? publicationDate; + String? seriesName; + double? seriesNumber; + int? rating; + String? coverUrl; + + FileImportItem({ + required this.fileName, + required this.fileSize, + required this.bytes, + required this.format, + this.status = FileImportStatus.pending, + this.errorMessage, + this.metadata, + this.coverImageBytes, + this.coverImageMimeType, + this.fileHash, + this.title = '', + this.author = '', + this.subtitle, + this.publisher, + this.language, + this.isbn, + this.isbn13, + this.description, + this.pageCount, + this.coAuthors = const [], + this.publicationDate, + this.seriesName, + this.seriesNumber, + this.rating, + this.coverUrl, + }); +} + +/// Provider for managing the digital book import pipeline. +class AddBookProvider extends ChangeNotifier { + List _items = []; + bool _isProcessing = false; + bool _isPicking = false; + bool _isAddingToLibrary = false; + bool _cancelled = false; + int _processedCount = 0; + + List get items => _items; + bool get isProcessing => _isProcessing; + bool get isPicking => _isPicking; + bool get isAddingToLibrary => _isAddingToLibrary; + int get processedCount => _processedCount; + int get totalCount => _items.length; + + /// Sets internal state for widget tests. Not for production use. + @visibleForTesting + void setTestState({ + List? items, + bool? isPicking, + bool? isProcessing, + bool? isAddingToLibrary, + int? processedCount, + }) { + if (items != null) _items = items; + if (isPicking != null) _isPicking = isPicking; + if (isProcessing != null) _isProcessing = isProcessing; + if (isAddingToLibrary != null) _isAddingToLibrary = isAddingToLibrary; + if (processedCount != null) _processedCount = processedCount; + notifyListeners(); + } + + int get successCount => + _items.where((i) => i.status == FileImportStatus.success).length; + + bool get hasSuccessfulItems => successCount > 0; + + /// Pick files using the system file picker. + /// Returns true if files were selected. + Future pickFiles() async { + _isPicking = true; + notifyListeners(); + + try { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.custom, + allowedExtensions: ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz'], + withData: true, + ); + + _isPicking = false; + + if (result == null || result.files.isEmpty) { + notifyListeners(); + return false; + } + + _items = result.files.where((f) => f.bytes != null).map((f) { + final ext = p.extension(f.name).toLowerCase(); + return FileImportItem( + fileName: f.name, + fileSize: f.size, + bytes: f.bytes!, + format: _formatFromExtension(ext), + title: p.basenameWithoutExtension(f.name), + ); + }).toList(); + + notifyListeners(); + return _items.isNotEmpty; + } catch (_) { + _isPicking = false; + notifyListeners(); + return false; + } + } + + /// Process all files: extract metadata and compute hashes. + /// + /// SHA-256 hashing and metadata extraction run together in a single + /// [compute] call — off the main thread on native platforms. + /// On web, [compute] runs on the main thread (no isolate/web worker + /// support), so we use [_ensureFramePainted] to guarantee the browser + /// displays progress before the blocking work begins. + Future processFiles(DataStore dataStore) async { + _isProcessing = true; + _cancelled = false; + _processedCount = 0; + notifyListeners(); + + // Let Flutter build/paint the processing UI before blocking work starts. + await _ensureFramePainted(); + + for (var i = 0; i < _items.length; i++) { + if (_cancelled) break; + + final item = _items[i]; + item.status = FileImportStatus.extracting; + notifyListeners(); + + // Ensure the extracting status is visible before heavy work. + await _ensureFramePainted(); + if (_cancelled) break; + + try { + final _FileProcessResult result; + if (kIsWeb) { + // Web: compute() runs on the main thread (no isolate support). + // Hash via Web Crypto API (async, non-blocking). + final hash = await computeSha256(item.bytes); + await _ensureFramePainted(); + if (_cancelled) break; + // Metadata extraction blocks the main thread (unavoidable without + // replacing parsing libraries), but is bounded per file. + final metadata = await FileMetadataService().extractMetadata( + item.bytes, + item.fileName, + ); + result = _FileProcessResult(fileHash: hash, metadata: metadata); + } else { + // Native: real isolate, no UI impact. + result = await compute(_processFileInIsolate, ( + item.bytes, + item.fileName, + )); + } + + item.fileHash = result.fileHash; + + // Check for duplicates + final isDuplicate = dataStore.books.any( + (b) => b.fileHash == result.fileHash, + ); + if (isDuplicate) { + item.status = FileImportStatus.duplicate; + _processedCount++; + notifyListeners(); + continue; + } + + final metadata = result.metadata; + item.metadata = metadata; + item.coverImageBytes = metadata.coverImageBytes; + item.coverImageMimeType = metadata.coverImageMimeType; + + // Pre-fill editable fields from metadata + if (metadata.title != null && metadata.title!.isNotEmpty) { + item.title = metadata.title!; + } + if (metadata.primaryAuthor.isNotEmpty) { + item.author = metadata.primaryAuthor; + } + item.subtitle = metadata.subtitle; + item.publisher = metadata.publisher; + item.language = metadata.language; + item.isbn = metadata.isbn; + item.isbn13 = metadata.isbn13; + item.description = metadata.description; + item.pageCount = metadata.pageCount; + item.coAuthors = metadata.coAuthors; + item.publicationDate = metadata.publishedDate; + + item.status = FileImportStatus.success; + } catch (e) { + item.status = FileImportStatus.error; + item.errorMessage = 'Failed to process: $e'; + } + + _processedCount++; + notifyListeners(); + } + + _isProcessing = false; + notifyListeners(); + } + + /// Cancel the current processing loop. + void cancelProcessing() { + _cancelled = true; + } + + /// Remove an item from the list. + void removeItem(int index) { + if (index >= 0 && index < _items.length) { + _items.removeAt(index); + notifyListeners(); + } + } + + /// Update editable fields on an item. + void updateItem( + int index, { + String? title, + String? author, + String? subtitle, + String? publisher, + String? language, + String? isbn, + String? isbn13, + String? description, + int? pageCount, + List? coAuthors, + String? publicationDate, + String? seriesName, + double? seriesNumber, + int? rating, + Uint8List? coverImageBytes, + String? coverUrl, + bool clearCoverImage = false, + bool clearCoverUrl = false, + }) { + if (index < 0 || index >= _items.length) return; + final item = _items[index]; + if (title != null) item.title = title; + if (author != null) item.author = author; + if (subtitle != null) item.subtitle = subtitle; + if (publisher != null) item.publisher = publisher; + if (language != null) item.language = language; + if (isbn != null) item.isbn = isbn; + if (isbn13 != null) item.isbn13 = isbn13; + if (description != null) item.description = description; + if (pageCount != null) item.pageCount = pageCount; + if (coAuthors != null) item.coAuthors = coAuthors; + if (publicationDate != null) item.publicationDate = publicationDate; + if (seriesName != null) item.seriesName = seriesName; + if (seriesNumber != null) item.seriesNumber = seriesNumber; + if (rating != null) item.rating = rating; + + if (coverImageBytes != null) { + item.coverImageBytes = coverImageBytes; + item.coverUrl = null; + } else if (clearCoverImage) { + item.coverImageBytes = null; + } + + if (coverUrl != null) { + item.coverUrl = coverUrl; + item.coverImageBytes = null; + } else if (clearCoverUrl) { + item.coverUrl = null; + } + + notifyListeners(); + } + + /// Clear the rating on an item. + void clearItemRating(int index) { + if (index < 0 || index >= _items.length) return; + _items[index].rating = null; + notifyListeners(); + } + + /// Add all successful items to the library. + Future addToLibrary(DataStore dataStore) async { + _isAddingToLibrary = true; + notifyListeners(); + + var addedCount = 0; + final now = DateTime.now(); + + for (var i = 0; i < _items.length; i++) { + final item = _items[i]; + if (item.status != FileImportStatus.success) continue; + if (item.title.trim().isEmpty || item.author.trim().isEmpty) continue; + + String? coverUrl; + if (item.coverImageBytes != null) { + coverUrl = _bytesToDataUri( + item.coverImageBytes!, + item.coverImageMimeType, + ); + } else if (item.coverUrl != null && item.coverUrl!.isNotEmpty) { + coverUrl = item.coverUrl; + } + + final book = Book( + id: 'book-${now.millisecondsSinceEpoch}-$i', + title: item.title.trim(), + subtitle: item.subtitle, + author: item.author.trim(), + coAuthors: item.coAuthors, + isbn: item.isbn, + isbn13: item.isbn13, + publisher: item.publisher, + language: item.language, + pageCount: item.pageCount, + description: item.description, + coverUrl: coverUrl, + fileFormat: item.format, + fileSize: item.fileSize, + fileHash: item.fileHash, + publicationDate: _tryParseDate(item.publicationDate), + rating: item.rating, + seriesName: item.seriesName, + seriesNumber: item.seriesNumber, + addedAt: now, + ); + + dataStore.addBook(book); + addedCount++; + } + + _isAddingToLibrary = false; + notifyListeners(); + return addedCount; + } + + /// Convert image bytes to a data URI string. + String _bytesToDataUri(Uint8List bytes, String? mimeType) { + String mime = mimeType ?? 'image/jpeg'; + if (mime.isEmpty) { + // Detect from magic bytes + if (bytes.length >= 8) { + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + mime = 'image/png'; + } else if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { + mime = 'image/gif'; + } else if (bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + mime = 'image/webp'; + } + } + } + final base64Data = base64Encode(bytes); + return 'data:$mime;base64,$base64Data'; + } + + /// Try to parse a date string in various formats. + DateTime? _tryParseDate(String? dateStr) { + if (dateStr == null || dateStr.isEmpty) return null; + final fullDate = DateTime.tryParse(dateStr); + if (fullDate != null) return fullDate; + if (RegExp(r'^\d{4}-\d{2}$').hasMatch(dateStr)) { + return DateTime.tryParse('$dateStr-01'); + } + final year = int.tryParse(dateStr); + if (year != null && year > 0 && year < 10000) { + return DateTime(year); + } + return null; + } + + /// Map file extension to BookFormat. + BookFormat _formatFromExtension(String ext) { + switch (ext) { + case '.epub': + return BookFormat.epub; + case '.pdf': + return BookFormat.pdf; + case '.mobi': + return BookFormat.mobi; + case '.azw3' || '.azw': + return BookFormat.azw3; + case '.txt': + return BookFormat.txt; + case '.cbr': + return BookFormat.cbr; + case '.cbz': + return BookFormat.cbz; + default: + return BookFormat.epub; + } + } +} diff --git a/client/lib/widgets/add_book/file_import_sheet.dart b/client/lib/widgets/add_book/file_import_sheet.dart new file mode 100644 index 0000000..ccc84a9 --- /dev/null +++ b/client/lib/widgets/add_book/file_import_sheet.dart @@ -0,0 +1,398 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/add_book_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/add_book/file_import_item_card.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:provider/provider.dart'; + +/// Sheet for the digital book import flow: file picking, processing, and review. +class FileImportSheet extends StatelessWidget { + const FileImportSheet({super.key}); + + /// Show the import sheet immediately, then pick files. + static Future show(BuildContext context) async { + final provider = AddBookProvider(); + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth >= Breakpoints.desktopSmall; + + if (isDesktop) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ChangeNotifierProvider.value( + value: provider, + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.dialog), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: const ImportContent(isDesktop: true), + ), + ), + ), + ); + } else { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + enableDrag: false, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppRadius.xl), + ), + ), + builder: (context) => ChangeNotifierProvider.value( + value: provider, + child: DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => ImportContent( + isDesktop: false, + scrollController: scrollController, + ), + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} + +@visibleForTesting +class ImportContent extends StatefulWidget { + final bool isDesktop; + final ScrollController? scrollController; + + /// When false, skips auto-launching the file picker in initState. + /// Used by tests to render the widget in a pre-configured provider state. + @visibleForTesting + final bool autoPick; + + const ImportContent({ + super.key, + required this.isDesktop, + this.scrollController, + this.autoPick = true, + }); + + @override + State createState() => ImportContentState(); +} + +class ImportContentState extends State { + bool _hasStartedPicking = false; + + @override + void initState() { + super.initState(); + if (widget.autoPick) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _startPicking(); + }); + } + } + + Future _startPicking() async { + if (_hasStartedPicking) return; + _hasStartedPicking = true; + + final provider = context.read(); + final hasPicked = await provider.pickFiles(); + + if (!mounted) return; + + if (!hasPicked) { + Navigator.of(context).pop(); + return; + } + + final dataStore = context.read(); + provider.processFiles(dataStore); + } + + Future _showCancelDialog() async { + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel import?'), + content: const Text( + 'Files that have been processed will be discarded.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Keep going'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Cancel import'), + ), + ], + ), + ); + return result ?? false; + } + + Future _handleCancel() async { + final shouldCancel = await _showCancelDialog(); + if (shouldCancel && mounted) { + final provider = context.read(); + provider.cancelProcessing(); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + + final isPicking = provider.isPicking; + final isProcessing = provider.isProcessing; + final isReview = !isPicking && !isProcessing && provider.items.isNotEmpty; + + final firstSuccessIndex = isReview + ? provider.items.indexWhere((i) => i.status == FileImportStatus.success) + : -1; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + if (isPicking) return; + await _handleCancel(); + }, + child: Padding( + padding: EdgeInsets.only( + left: Spacing.lg, + right: Spacing.lg, + top: Spacing.md, + bottom: widget.isDesktop + ? Spacing.lg + : viewInsets.bottom + Spacing.lg, + ), + child: ListView( + controller: widget.scrollController, + children: [ + if (!widget.isDesktop) ...[ + const BottomSheetHandle(), + const SizedBox(height: Spacing.lg), + ], + _buildHeader( + textTheme: textTheme, + colorScheme: colorScheme, + provider: provider, + isPicking: isPicking, + isProcessing: isProcessing, + isReview: isReview, + ), + const SizedBox(height: Spacing.md), + if (isPicking) + SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: Spacing.md), + Text( + 'Opening file picker...', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ) + else ...[ + for (var i = 0; i < provider.items.length; i++) ...[ + if (i > 0) const SizedBox(height: Spacing.sm), + FileImportItemCard( + item: provider.items[i], + index: i, + isReviewPhase: isReview, + isDesktop: widget.isDesktop, + initiallyExpanded: isReview && i == firstSuccessIndex, + onRemove: isReview ? (i) => provider.removeItem(i) : null, + ), + ], + const SizedBox(height: Spacing.md), + _buildFooter( + provider: provider, + isPicking: isPicking, + isProcessing: isProcessing, + isReview: isReview, + ), + ], + ], + ), + ), + ); + } + + // ============================================================================ + // HEADER + // ============================================================================ + + Widget _buildHeader({ + required TextTheme textTheme, + required ColorScheme colorScheme, + required AddBookProvider provider, + required bool isPicking, + required bool isProcessing, + required bool isReview, + }) { + if (isPicking) { + return Text('Import digital books', style: textTheme.headlineSmall); + } + + if (isProcessing) { + final totalCount = provider.totalCount; + final processedCount = provider.processedCount; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Importing $totalCount ${totalCount == 1 ? 'book' : 'books'}', + style: textTheme.headlineSmall, + ), + const SizedBox(height: Spacing.sm), + LinearProgressIndicator( + value: totalCount > 0 ? processedCount / totalCount : 0, + ), + const SizedBox(height: Spacing.sm), + Text( + '$processedCount of $totalCount processed', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + if (isReview) { + final successCount = provider.successCount; + final totalCount = provider.totalCount; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + successCount == 1 + ? 'Review imported book' + : 'Review imported books', + style: textTheme.headlineSmall, + ), + const SizedBox(height: Spacing.xs), + Text( + '$successCount of $totalCount ${totalCount == 1 ? 'book' : 'books'} ready to add', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + return Text('Import digital books', style: textTheme.headlineSmall); + } + + // ============================================================================ + // FOOTER + // ============================================================================ + + Widget _buildFooter({ + required AddBookProvider provider, + required bool isPicking, + required bool isProcessing, + required bool isReview, + }) { + // No buttons during picking + if (isPicking) return const SizedBox.shrink(); + + if (isProcessing) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton(onPressed: _handleCancel, child: const Text('Cancel')), + ], + ); + } + + if (isReview) { + final successCount = provider.successCount; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: _handleCancel, child: const Text('Cancel')), + const SizedBox(width: Spacing.sm), + FilledButton( + onPressed: + provider.hasSuccessfulItems && !provider.isAddingToLibrary + ? () => _addToLibrary(provider) + : null, + child: provider.isAddingToLibrary + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + successCount == 1 + ? 'Add to library' + : 'Add $successCount books to library', + ), + ), + ], + ); + } + + return const SizedBox.shrink(); + } + + // ============================================================================ + // ACTIONS + // ============================================================================ + + Future _addToLibrary(AddBookProvider provider) async { + final dataStore = context.read(); + final count = await provider.addToLibrary(dataStore); + + if (!mounted) return; + + // Capture messenger before popping (context becomes invalid after pop) + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + + messenger.showSnackBar( + SnackBar( + content: Text( + count == 1 + ? 'Added 1 book to library' + : 'Added $count books to library', + ), + ), + ); + } +} 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/test/widgets/add_book/file_import_sheet_test.dart b/client/test/widgets/add_book/file_import_sheet_test.dart new file mode 100644 index 0000000..3aba332 --- /dev/null +++ b/client/test/widgets/add_book/file_import_sheet_test.dart @@ -0,0 +1,338 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/add_book_provider.dart'; +import 'package:papyrus/widgets/add_book/file_import_item_card.dart'; +import 'package:papyrus/widgets/add_book/file_import_sheet.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:provider/provider.dart'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// Creates a [FileImportItem] with minimal test data. +FileImportItem _testItem({ + String fileName = 'test.epub', + FileImportStatus status = FileImportStatus.success, + String title = 'Test Book', + String author = 'Test Author', + String? errorMessage, +}) { + return FileImportItem( + fileName: fileName, + fileSize: 1024, + bytes: Uint8List(0), + format: BookFormat.epub, + status: status, + title: title, + author: author, + errorMessage: errorMessage, + ); +} + +/// Builds the [ImportContent] widget inside a mobile-like bottom sheet context. +/// +/// Uses [DraggableScrollableSheet] to reproduce the real constraints the widget +/// receives on mobile (scroll controller from the sheet, bounded height from the +/// sheet's fraction of screen). +Widget _buildMobileSheet({ + required AddBookProvider provider, + DataStore? dataStore, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: dataStore ?? DataStore()), + ], + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => SizedBox( + width: 420, + height: 800, + child: DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) => ImportContent( + isDesktop: false, + scrollController: scrollController, + autoPick: false, + ), + ), + ), + ), + ), + ), + ); +} + +/// Builds the [ImportContent] widget inside a desktop-like dialog context. +/// +/// Uses [ConstrainedBox] to reproduce the real constraints from the +/// Dialog + ConstrainedBox wrapper used in [FileImportSheet.show]. +Widget _buildDesktopDialog({ + required AddBookProvider provider, + DataStore? dataStore, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: dataStore ?? DataStore()), + ], + child: MaterialApp( + home: Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640, maxHeight: 600), + child: const Material( + child: ImportContent(isDesktop: true, autoPick: false), + ), + ), + ), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('FileImportSheet - mobile (DraggableScrollableSheet)', () { + testWidgets('picking state renders without exceptions', (tester) async { + final provider = AddBookProvider(); + provider.setTestState(isPicking: true); + + await tester.pumpWidget(_buildMobileSheet(provider: provider)); + await tester.pump(); + + // Should show the picking spinner and header + expect(find.text('Import digital books'), findsOneWidget); + expect(find.text('Opening file picker...'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // Should show bottom sheet handle + expect(find.byType(BottomSheetHandle), findsOneWidget); + // No exceptions in the render tree + expect(tester.takeException(), isNull); + }); + + testWidgets('processing state renders items and cancel button', ( + tester, + ) async { + final provider = AddBookProvider(); + provider.setTestState( + isProcessing: true, + processedCount: 1, + items: [ + _testItem(status: FileImportStatus.success, fileName: 'book1.epub'), + _testItem( + status: FileImportStatus.extracting, + fileName: 'book2.epub', + ), + _testItem(status: FileImportStatus.pending, fileName: 'book3.epub'), + ], + ); + + await tester.pumpWidget(_buildMobileSheet(provider: provider)); + await tester.pump(); + + // Should show the progress header + expect(find.text('Importing 3 books'), findsOneWidget); + expect(find.text('1 of 3 processed'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + + // Scroll to cancel button to ensure it's reachable + await tester.scrollUntilVisible( + find.widgetWithText(OutlinedButton, 'Cancel'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + + // All 3 item cards should now be visible/built + expect( + find.byType(FileImportItemCard, skipOffstage: false), + findsNWidgets(3), + ); + + // Cancel button is now visible + expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); + + expect(tester.takeException(), isNull); + }); + + testWidgets('review state: footer buttons are reachable by scrolling', ( + tester, + ) async { + final provider = AddBookProvider(); + provider.setTestState( + items: [ + _testItem( + status: FileImportStatus.success, + title: 'Good Book', + author: 'Author A', + ), + _testItem(status: FileImportStatus.duplicate, fileName: 'dup.epub'), + _testItem( + status: FileImportStatus.error, + fileName: 'bad.epub', + errorMessage: 'Parse failed', + ), + ], + ); + + await tester.pumpWidget(_buildMobileSheet(provider: provider)); + await tester.pump(); + + // Should show review header + expect(find.text('Review imported book'), findsOneWidget); + expect(find.text('1 of 3 books ready to add'), findsOneWidget); + + // Scroll to the "Add to library" button to verify it's reachable + await tester.scrollUntilVisible( + find.widgetWithText(FilledButton, 'Add to library'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + + // Both footer buttons must be visible after scrolling + expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); + expect( + find.widgetWithText(FilledButton, 'Add to library'), + findsOneWidget, + ); + + expect(tester.takeException(), isNull); + }); + }); + + group('FileImportSheet - desktop (Dialog)', () { + testWidgets('picking state renders without exceptions', (tester) async { + final provider = AddBookProvider(); + provider.setTestState(isPicking: true); + + await tester.pumpWidget(_buildDesktopDialog(provider: provider)); + await tester.pump(); + + expect(find.text('Import digital books'), findsOneWidget); + expect(find.text('Opening file picker...'), findsOneWidget); + // No bottom sheet handle on desktop + expect(find.byType(BottomSheetHandle), findsNothing); + + expect(tester.takeException(), isNull); + }); + + testWidgets('processing state renders items and cancel button', ( + tester, + ) async { + final provider = AddBookProvider(); + provider.setTestState( + isProcessing: true, + processedCount: 0, + items: [ + _testItem(status: FileImportStatus.extracting, fileName: 'book1.pdf'), + _testItem(status: FileImportStatus.pending, fileName: 'book2.pdf'), + ], + ); + + await tester.pumpWidget(_buildDesktopDialog(provider: provider)); + await tester.pump(); + + expect(find.text('Importing 2 books'), findsOneWidget); + + // Scroll to cancel button to ensure it's reachable + await tester.scrollUntilVisible( + find.widgetWithText(OutlinedButton, 'Cancel'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + + expect( + find.byType(FileImportItemCard, skipOffstage: false), + findsNWidgets(2), + ); + expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); + + expect(tester.takeException(), isNull); + }); + + testWidgets('review state: footer buttons are reachable by scrolling', ( + tester, + ) async { + final provider = AddBookProvider(); + provider.setTestState( + items: [ + _testItem( + status: FileImportStatus.success, + title: 'Desktop Book', + author: 'Author B', + ), + ], + ); + + await tester.pumpWidget(_buildDesktopDialog(provider: provider)); + await tester.pump(); + + expect(find.text('Review imported book'), findsOneWidget); + expect(find.byType(FileImportItemCard), findsOneWidget); + + // Scroll to the "Add to library" button + await tester.scrollUntilVisible( + find.widgetWithText(FilledButton, 'Add to library'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + + expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); + expect( + find.widgetWithText(FilledButton, 'Add to library'), + findsOneWidget, + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('review with multiple successes shows correct button text', ( + tester, + ) async { + final provider = AddBookProvider(); + provider.setTestState( + items: [ + _testItem(status: FileImportStatus.success, title: 'Book 1'), + _testItem(status: FileImportStatus.success, title: 'Book 2'), + _testItem(status: FileImportStatus.success, title: 'Book 3'), + ], + ); + + await tester.pumpWidget(_buildDesktopDialog(provider: provider)); + await tester.pump(); + + expect(find.text('Review imported books'), findsOneWidget); + expect(find.text('3 of 3 books ready to add'), findsOneWidget); + + // Scroll to the multi-book button + await tester.scrollUntilVisible( + find.widgetWithText(FilledButton, 'Add 3 books to library'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + + expect( + find.widgetWithText(FilledButton, 'Add 3 books to library'), + findsOneWidget, + ); + + expect(tester.takeException(), isNull); + }); + }); +} From b0cf4edea972e8a298ceeeafff4623da10139130 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 9 Feb 2026 03:28:58 +0200 Subject: [PATCH 4/6] Add integration tests for FileImportSheet.show() flow. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test the full show() → FilePicker → bottom sheet / dialog lifecycle using a FakeFilePicker with MockPlatformInterfaceMixin. Covers: - Mobile: sheet opens and closes when picker is cancelled - Mobile: sheet transitions through picking → processing states - Desktop: dialog opens and closes when picker is cancelled Add plugin_platform_interface as dev dependency for the mock mixin. --- client/pubspec.lock | 14 +- client/pubspec.yaml | 4 + .../add_book/file_import_sheet_test.dart | 171 ++++++++++++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/client/pubspec.lock b/client/pubspec.lock index ec7f5cb..6a03916 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -114,7 +114,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -472,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: @@ -585,7 +593,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -806,7 +814,7 @@ packages: source: hosted version: "15.0.2" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 093437b..8f3cf91 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -32,12 +32,16 @@ dependencies: 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/widgets/add_book/file_import_sheet_test.dart b/client/test/widgets/add_book/file_import_sheet_test.dart index 3aba332..1509ff8 100644 --- a/client/test/widgets/add_book/file_import_sheet_test.dart +++ b/client/test/widgets/add_book/file_import_sheet_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; @@ -8,8 +9,39 @@ import 'package:papyrus/providers/add_book_provider.dart'; import 'package:papyrus/widgets/add_book/file_import_item_card.dart'; import 'package:papyrus/widgets/add_book/file_import_sheet.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:provider/provider.dart'; +// --------------------------------------------------------------------------- +// Fake FilePicker for integration tests +// --------------------------------------------------------------------------- + +/// A fake [FilePicker] that returns a pre-configured result (or null). +/// +/// Uses [MockPlatformInterfaceMixin] to bypass the platform interface +/// token verification so it can be set via [FilePicker.platform]. +class FakeFilePicker extends FilePicker with MockPlatformInterfaceMixin { + FilePickerResult? resultToReturn; + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + bool allowCompression = true, + int compressionQuality = 30, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + bool readSequential = false, + }) async { + return resultToReturn; + } +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- @@ -335,4 +367,143 @@ void main() { expect(tester.takeException(), isNull); }); }); + + // ========================================================================= + // Integration: FileImportSheet.show() through real showModalBottomSheet + // ========================================================================= + + group('FileImportSheet.show() integration', () { + late FakeFilePicker fakePicker; + + setUp(() { + fakePicker = FakeFilePicker(); + FilePicker.platform = fakePicker; + }); + + testWidgets('mobile: opens bottom sheet without rendering exceptions', ( + tester, + ) async { + // Picker returns null (user cancels) — sheet should open, show + // the picking state, then close automatically. + fakePicker.resultToReturn = null; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: DataStore(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => FileImportSheet.show(context), + child: const Text('Import'), + ), + ), + ), + ), + ), + ); + + // Tap the button to trigger show() + await tester.tap(find.text('Import')); + // Pump once to trigger the post-frame callback and open the sheet + await tester.pump(); + // Pump again for the sheet animation to begin + await tester.pump(); + + // The sheet should have opened and rendered the picking state. + // If DraggableScrollableSheet passes unconstrained width, a + // rendering exception would be caught here. + expect(tester.takeException(), isNull); + + // Let the picker future resolve (null → sheet auto-closes) + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('mobile: processes picked files without rendering exceptions', ( + tester, + ) async { + // Picker returns a file + fakePicker.resultToReturn = FilePickerResult([ + PlatformFile( + name: 'test-book.epub', + size: 100, + bytes: Uint8List.fromList([0x50, 0x4B, 0x03, 0x04]), + ), + ]); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: DataStore(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => FileImportSheet.show(context), + child: const Text('Import'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Import')); + await tester.pump(); + await tester.pump(); + + // No rendering exception during the picking → processing transition + expect(tester.takeException(), isNull); + + // Pump several frames to let processing UI render + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + final exception = tester.takeException(); + if (exception != null) { + fail('Rendering exception on frame $i: $exception'); + } + } + }); + + testWidgets('desktop: opens dialog without rendering exceptions', ( + tester, + ) async { + fakePicker.resultToReturn = null; + + // Use a wide screen to trigger the desktop path (>= 840px) + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: DataStore(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => FileImportSheet.show(context), + child: const Text('Import'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Import')); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + }); } From a02969c57b5c0340c8d176693f85628d4fd3025f Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 9 Feb 2026 03:47:13 +0200 Subject: [PATCH 5/6] Guard ListView with LayoutBuilder against unbounded width on web. DraggableScrollableSheet passes unconstrained cross-axis width during its first layout frame on web, causing Material widgets like OutlinedButton to crash with "BoxConstraints forces an infinite width". Return SizedBox.shrink() for that transient frame. Co-Authored-By: Claude Opus 4.6 --- .../widgets/add_book/file_import_sheet.dart | 117 ++++++++++-------- 1 file changed, 64 insertions(+), 53 deletions(-) diff --git a/client/lib/widgets/add_book/file_import_sheet.dart b/client/lib/widgets/add_book/file_import_sheet.dart index ccc84a9..499b72e 100644 --- a/client/lib/widgets/add_book/file_import_sheet.dart +++ b/client/lib/widgets/add_book/file_import_sheet.dart @@ -185,62 +185,73 @@ class ImportContentState extends State { ? Spacing.lg : viewInsets.bottom + Spacing.lg, ), - child: ListView( - controller: widget.scrollController, - children: [ - if (!widget.isDesktop) ...[ - const BottomSheetHandle(), - const SizedBox(height: Spacing.lg), - ], - _buildHeader( - textTheme: textTheme, - colorScheme: colorScheme, - provider: provider, - isPicking: isPicking, - isProcessing: isProcessing, - isReview: isReview, - ), - const SizedBox(height: Spacing.md), - if (isPicking) - SizedBox( - height: 200, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: Spacing.md), - Text( - 'Opening file picker...', - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + child: LayoutBuilder( + builder: (context, constraints) { + // DraggableScrollableSheet passes unconstrained cross-axis width + // during its first layout frame on web. Return an empty widget for + // that transient frame to avoid BoxConstraints infinite width + // crashes in Material widgets like OutlinedButton. + if (!constraints.hasBoundedWidth) { + return const SizedBox.shrink(); + } + return ListView( + controller: widget.scrollController, + children: [ + if (!widget.isDesktop) ...[ + const BottomSheetHandle(), + const SizedBox(height: Spacing.lg), + ], + _buildHeader( + textTheme: textTheme, + colorScheme: colorScheme, + provider: provider, + isPicking: isPicking, + isProcessing: isProcessing, + isReview: isReview, + ), + const SizedBox(height: Spacing.md), + if (isPicking) + SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: Spacing.md), + Text( + 'Opening file picker...', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ), - ], + ), + ) + else ...[ + for (var i = 0; i < provider.items.length; i++) ...[ + if (i > 0) const SizedBox(height: Spacing.sm), + FileImportItemCard( + item: provider.items[i], + index: i, + isReviewPhase: isReview, + isDesktop: widget.isDesktop, + initiallyExpanded: isReview && i == firstSuccessIndex, + onRemove: isReview ? (i) => provider.removeItem(i) : null, + ), + ], + const SizedBox(height: Spacing.md), + _buildFooter( + provider: provider, + isPicking: isPicking, + isProcessing: isProcessing, + isReview: isReview, ), - ), - ) - else ...[ - for (var i = 0; i < provider.items.length; i++) ...[ - if (i > 0) const SizedBox(height: Spacing.sm), - FileImportItemCard( - item: provider.items[i], - index: i, - isReviewPhase: isReview, - isDesktop: widget.isDesktop, - initiallyExpanded: isReview && i == firstSuccessIndex, - onRemove: isReview ? (i) => provider.removeItem(i) : null, - ), + ], ], - const SizedBox(height: Spacing.md), - _buildFooter( - provider: provider, - isPicking: isPicking, - isProcessing: isProcessing, - isReview: isReview, - ), - ], - ], + ); + }, ), ), ); From e803470830159ad62e2e0f2886ec41ddf5a93d40 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 9 Feb 2026 04:08:39 +0200 Subject: [PATCH 6/6] Remove digital book import flow. --- client/lib/pages/library_page.dart | 9 +- client/lib/providers/add_book_provider.dart | 481 ---------------- .../add_book/add_book_choice_sheet.dart | 150 +++++ .../add_book/add_physical_book_sheet.dart | 525 ++++++++++++++++++ .../widgets/add_book/file_import_sheet.dart | 409 -------------- .../widgets/add_book/isbn_scanner_dialog.dart | 134 +++++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../add_book/file_import_sheet_test.dart | 509 ----------------- 8 files changed, 814 insertions(+), 1405 deletions(-) delete mode 100644 client/lib/providers/add_book_provider.dart create mode 100644 client/lib/widgets/add_book/add_book_choice_sheet.dart create mode 100644 client/lib/widgets/add_book/add_physical_book_sheet.dart delete mode 100644 client/lib/widgets/add_book/file_import_sheet.dart create mode 100644 client/lib/widgets/add_book/isbn_scanner_dialog.dart delete mode 100644 client/test/widgets/add_book/file_import_sheet_test.dart 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/providers/add_book_provider.dart b/client/lib/providers/add_book_provider.dart deleted file mode 100644 index 488a07c..0000000 --- a/client/lib/providers/add_book_provider.dart +++ /dev/null @@ -1,481 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/models/book.dart'; -import 'package:papyrus/services/file_metadata_service.dart'; -import 'package:papyrus/services/sha256_hash.dart'; -import 'package:path/path.dart' as p; - -/// Wait until the next frame is painted and the browser has composited. -/// -/// After the post-frame callback fires, a short [Future.delayed] exits the -/// requestAnimationFrame callback so the browser can actually composite pixels -/// to screen before execution continues. Without this, the next synchronous -/// work starts before any visual update is displayed. -Future _ensureFramePainted() async { - final completer = Completer(); - SchedulerBinding.instance.addPostFrameCallback((_) { - completer.complete(); - }); - SchedulerBinding.instance.scheduleFrame(); - await completer.future; - // Exit rAF so browser can composite. 16ms ≈ one frame at 60fps. - await Future.delayed(const Duration(milliseconds: 16)); -} - -/// Combined result of hashing and metadata extraction from an isolate. -class _FileProcessResult { - final String fileHash; - final FileMetadataResult metadata; - const _FileProcessResult({required this.fileHash, required this.metadata}); -} - -/// Top-level entry point for compute(). Runs SHA-256 hashing and metadata -/// extraction in a single call — off the main thread on native platforms. -Future<_FileProcessResult> _processFileInIsolate( - (Uint8List, String) args, -) async { - final (bytes, filename) = args; - final hash = sha256.convert(bytes).toString(); - final metadata = await FileMetadataService().extractMetadata(bytes, filename); - return _FileProcessResult(fileHash: hash, metadata: metadata); -} - -/// Status of a single file import. -enum FileImportStatus { pending, extracting, success, duplicate, error } - -/// A single file being imported. -class FileImportItem { - final String fileName; - final int fileSize; - final Uint8List bytes; - final BookFormat format; - FileImportStatus status; - String? errorMessage; - FileMetadataResult? metadata; - Uint8List? coverImageBytes; - String? coverImageMimeType; - String? fileHash; - - // Editable fields (pre-filled from metadata) - String title; - String author; - String? subtitle; - String? publisher; - String? language; - String? isbn; - String? isbn13; - String? description; - int? pageCount; - List coAuthors; - String? publicationDate; - String? seriesName; - double? seriesNumber; - int? rating; - String? coverUrl; - - FileImportItem({ - required this.fileName, - required this.fileSize, - required this.bytes, - required this.format, - this.status = FileImportStatus.pending, - this.errorMessage, - this.metadata, - this.coverImageBytes, - this.coverImageMimeType, - this.fileHash, - this.title = '', - this.author = '', - this.subtitle, - this.publisher, - this.language, - this.isbn, - this.isbn13, - this.description, - this.pageCount, - this.coAuthors = const [], - this.publicationDate, - this.seriesName, - this.seriesNumber, - this.rating, - this.coverUrl, - }); -} - -/// Provider for managing the digital book import pipeline. -class AddBookProvider extends ChangeNotifier { - List _items = []; - bool _isProcessing = false; - bool _isPicking = false; - bool _isAddingToLibrary = false; - bool _cancelled = false; - int _processedCount = 0; - - List get items => _items; - bool get isProcessing => _isProcessing; - bool get isPicking => _isPicking; - bool get isAddingToLibrary => _isAddingToLibrary; - int get processedCount => _processedCount; - int get totalCount => _items.length; - - /// Sets internal state for widget tests. Not for production use. - @visibleForTesting - void setTestState({ - List? items, - bool? isPicking, - bool? isProcessing, - bool? isAddingToLibrary, - int? processedCount, - }) { - if (items != null) _items = items; - if (isPicking != null) _isPicking = isPicking; - if (isProcessing != null) _isProcessing = isProcessing; - if (isAddingToLibrary != null) _isAddingToLibrary = isAddingToLibrary; - if (processedCount != null) _processedCount = processedCount; - notifyListeners(); - } - - int get successCount => - _items.where((i) => i.status == FileImportStatus.success).length; - - bool get hasSuccessfulItems => successCount > 0; - - /// Pick files using the system file picker. - /// Returns true if files were selected. - Future pickFiles() async { - _isPicking = true; - notifyListeners(); - - try { - final result = await FilePicker.platform.pickFiles( - allowMultiple: true, - type: FileType.custom, - allowedExtensions: ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz'], - withData: true, - ); - - _isPicking = false; - - if (result == null || result.files.isEmpty) { - notifyListeners(); - return false; - } - - _items = result.files.where((f) => f.bytes != null).map((f) { - final ext = p.extension(f.name).toLowerCase(); - return FileImportItem( - fileName: f.name, - fileSize: f.size, - bytes: f.bytes!, - format: _formatFromExtension(ext), - title: p.basenameWithoutExtension(f.name), - ); - }).toList(); - - notifyListeners(); - return _items.isNotEmpty; - } catch (_) { - _isPicking = false; - notifyListeners(); - return false; - } - } - - /// Process all files: extract metadata and compute hashes. - /// - /// SHA-256 hashing and metadata extraction run together in a single - /// [compute] call — off the main thread on native platforms. - /// On web, [compute] runs on the main thread (no isolate/web worker - /// support), so we use [_ensureFramePainted] to guarantee the browser - /// displays progress before the blocking work begins. - Future processFiles(DataStore dataStore) async { - _isProcessing = true; - _cancelled = false; - _processedCount = 0; - notifyListeners(); - - // Let Flutter build/paint the processing UI before blocking work starts. - await _ensureFramePainted(); - - for (var i = 0; i < _items.length; i++) { - if (_cancelled) break; - - final item = _items[i]; - item.status = FileImportStatus.extracting; - notifyListeners(); - - // Ensure the extracting status is visible before heavy work. - await _ensureFramePainted(); - if (_cancelled) break; - - try { - final _FileProcessResult result; - if (kIsWeb) { - // Web: compute() runs on the main thread (no isolate support). - // Hash via Web Crypto API (async, non-blocking). - final hash = await computeSha256(item.bytes); - await _ensureFramePainted(); - if (_cancelled) break; - // Metadata extraction blocks the main thread (unavoidable without - // replacing parsing libraries), but is bounded per file. - final metadata = await FileMetadataService().extractMetadata( - item.bytes, - item.fileName, - ); - result = _FileProcessResult(fileHash: hash, metadata: metadata); - } else { - // Native: real isolate, no UI impact. - result = await compute(_processFileInIsolate, ( - item.bytes, - item.fileName, - )); - } - - item.fileHash = result.fileHash; - - // Check for duplicates - final isDuplicate = dataStore.books.any( - (b) => b.fileHash == result.fileHash, - ); - if (isDuplicate) { - item.status = FileImportStatus.duplicate; - _processedCount++; - notifyListeners(); - continue; - } - - final metadata = result.metadata; - item.metadata = metadata; - item.coverImageBytes = metadata.coverImageBytes; - item.coverImageMimeType = metadata.coverImageMimeType; - - // Pre-fill editable fields from metadata - if (metadata.title != null && metadata.title!.isNotEmpty) { - item.title = metadata.title!; - } - if (metadata.primaryAuthor.isNotEmpty) { - item.author = metadata.primaryAuthor; - } - item.subtitle = metadata.subtitle; - item.publisher = metadata.publisher; - item.language = metadata.language; - item.isbn = metadata.isbn; - item.isbn13 = metadata.isbn13; - item.description = metadata.description; - item.pageCount = metadata.pageCount; - item.coAuthors = metadata.coAuthors; - item.publicationDate = metadata.publishedDate; - - item.status = FileImportStatus.success; - } catch (e) { - item.status = FileImportStatus.error; - item.errorMessage = 'Failed to process: $e'; - } - - _processedCount++; - notifyListeners(); - } - - _isProcessing = false; - notifyListeners(); - } - - /// Cancel the current processing loop. - void cancelProcessing() { - _cancelled = true; - } - - /// Remove an item from the list. - void removeItem(int index) { - if (index >= 0 && index < _items.length) { - _items.removeAt(index); - notifyListeners(); - } - } - - /// Update editable fields on an item. - void updateItem( - int index, { - String? title, - String? author, - String? subtitle, - String? publisher, - String? language, - String? isbn, - String? isbn13, - String? description, - int? pageCount, - List? coAuthors, - String? publicationDate, - String? seriesName, - double? seriesNumber, - int? rating, - Uint8List? coverImageBytes, - String? coverUrl, - bool clearCoverImage = false, - bool clearCoverUrl = false, - }) { - if (index < 0 || index >= _items.length) return; - final item = _items[index]; - if (title != null) item.title = title; - if (author != null) item.author = author; - if (subtitle != null) item.subtitle = subtitle; - if (publisher != null) item.publisher = publisher; - if (language != null) item.language = language; - if (isbn != null) item.isbn = isbn; - if (isbn13 != null) item.isbn13 = isbn13; - if (description != null) item.description = description; - if (pageCount != null) item.pageCount = pageCount; - if (coAuthors != null) item.coAuthors = coAuthors; - if (publicationDate != null) item.publicationDate = publicationDate; - if (seriesName != null) item.seriesName = seriesName; - if (seriesNumber != null) item.seriesNumber = seriesNumber; - if (rating != null) item.rating = rating; - - if (coverImageBytes != null) { - item.coverImageBytes = coverImageBytes; - item.coverUrl = null; - } else if (clearCoverImage) { - item.coverImageBytes = null; - } - - if (coverUrl != null) { - item.coverUrl = coverUrl; - item.coverImageBytes = null; - } else if (clearCoverUrl) { - item.coverUrl = null; - } - - notifyListeners(); - } - - /// Clear the rating on an item. - void clearItemRating(int index) { - if (index < 0 || index >= _items.length) return; - _items[index].rating = null; - notifyListeners(); - } - - /// Add all successful items to the library. - Future addToLibrary(DataStore dataStore) async { - _isAddingToLibrary = true; - notifyListeners(); - - var addedCount = 0; - final now = DateTime.now(); - - for (var i = 0; i < _items.length; i++) { - final item = _items[i]; - if (item.status != FileImportStatus.success) continue; - if (item.title.trim().isEmpty || item.author.trim().isEmpty) continue; - - String? coverUrl; - if (item.coverImageBytes != null) { - coverUrl = _bytesToDataUri( - item.coverImageBytes!, - item.coverImageMimeType, - ); - } else if (item.coverUrl != null && item.coverUrl!.isNotEmpty) { - coverUrl = item.coverUrl; - } - - final book = Book( - id: 'book-${now.millisecondsSinceEpoch}-$i', - title: item.title.trim(), - subtitle: item.subtitle, - author: item.author.trim(), - coAuthors: item.coAuthors, - isbn: item.isbn, - isbn13: item.isbn13, - publisher: item.publisher, - language: item.language, - pageCount: item.pageCount, - description: item.description, - coverUrl: coverUrl, - fileFormat: item.format, - fileSize: item.fileSize, - fileHash: item.fileHash, - publicationDate: _tryParseDate(item.publicationDate), - rating: item.rating, - seriesName: item.seriesName, - seriesNumber: item.seriesNumber, - addedAt: now, - ); - - dataStore.addBook(book); - addedCount++; - } - - _isAddingToLibrary = false; - notifyListeners(); - return addedCount; - } - - /// Convert image bytes to a data URI string. - String _bytesToDataUri(Uint8List bytes, String? mimeType) { - String mime = mimeType ?? 'image/jpeg'; - if (mime.isEmpty) { - // Detect from magic bytes - if (bytes.length >= 8) { - if (bytes[0] == 0x89 && - bytes[1] == 0x50 && - bytes[2] == 0x4E && - bytes[3] == 0x47) { - mime = 'image/png'; - } else if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { - mime = 'image/gif'; - } else if (bytes[0] == 0x52 && - bytes[1] == 0x49 && - bytes[2] == 0x46 && - bytes[3] == 0x46) { - mime = 'image/webp'; - } - } - } - final base64Data = base64Encode(bytes); - return 'data:$mime;base64,$base64Data'; - } - - /// Try to parse a date string in various formats. - DateTime? _tryParseDate(String? dateStr) { - if (dateStr == null || dateStr.isEmpty) return null; - final fullDate = DateTime.tryParse(dateStr); - if (fullDate != null) return fullDate; - if (RegExp(r'^\d{4}-\d{2}$').hasMatch(dateStr)) { - return DateTime.tryParse('$dateStr-01'); - } - final year = int.tryParse(dateStr); - if (year != null && year > 0 && year < 10000) { - return DateTime(year); - } - return null; - } - - /// Map file extension to BookFormat. - BookFormat _formatFromExtension(String ext) { - switch (ext) { - case '.epub': - return BookFormat.epub; - case '.pdf': - return BookFormat.pdf; - case '.mobi': - return BookFormat.mobi; - case '.azw3' || '.azw': - return BookFormat.azw3; - case '.txt': - return BookFormat.txt; - case '.cbr': - return BookFormat.cbr; - case '.cbz': - return BookFormat.cbz; - default: - return BookFormat.epub; - } - } -} 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/file_import_sheet.dart b/client/lib/widgets/add_book/file_import_sheet.dart deleted file mode 100644 index 499b72e..0000000 --- a/client/lib/widgets/add_book/file_import_sheet.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/providers/add_book_provider.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/add_book/file_import_item_card.dart'; -import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; -import 'package:provider/provider.dart'; - -/// Sheet for the digital book import flow: file picking, processing, and review. -class FileImportSheet extends StatelessWidget { - const FileImportSheet({super.key}); - - /// Show the import sheet immediately, then pick files. - static Future show(BuildContext context) async { - final provider = AddBookProvider(); - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - if (isDesktop) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => ChangeNotifierProvider.value( - value: provider, - child: Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.dialog), - ), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: MediaQuery.of(context).size.height * 0.85, - ), - child: const ImportContent(isDesktop: true), - ), - ), - ), - ); - } else { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: false, - enableDrag: false, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(AppRadius.xl), - ), - ), - builder: (context) => ChangeNotifierProvider.value( - value: provider, - child: DraggableScrollableSheet( - initialChildSize: 0.9, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => ImportContent( - isDesktop: false, - scrollController: scrollController, - ), - ), - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return const SizedBox.shrink(); - } -} - -@visibleForTesting -class ImportContent extends StatefulWidget { - final bool isDesktop; - final ScrollController? scrollController; - - /// When false, skips auto-launching the file picker in initState. - /// Used by tests to render the widget in a pre-configured provider state. - @visibleForTesting - final bool autoPick; - - const ImportContent({ - super.key, - required this.isDesktop, - this.scrollController, - this.autoPick = true, - }); - - @override - State createState() => ImportContentState(); -} - -class ImportContentState extends State { - bool _hasStartedPicking = false; - - @override - void initState() { - super.initState(); - if (widget.autoPick) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _startPicking(); - }); - } - } - - Future _startPicking() async { - if (_hasStartedPicking) return; - _hasStartedPicking = true; - - final provider = context.read(); - final hasPicked = await provider.pickFiles(); - - if (!mounted) return; - - if (!hasPicked) { - Navigator.of(context).pop(); - return; - } - - final dataStore = context.read(); - provider.processFiles(dataStore); - } - - Future _showCancelDialog() async { - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Cancel import?'), - content: const Text( - 'Files that have been processed will be discarded.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Keep going'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Cancel import'), - ), - ], - ), - ); - return result ?? false; - } - - Future _handleCancel() async { - final shouldCancel = await _showCancelDialog(); - if (shouldCancel && mounted) { - final provider = context.read(); - provider.cancelProcessing(); - Navigator.of(context).pop(); - } - } - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - final viewInsets = MediaQuery.of(context).viewInsets; - - final isPicking = provider.isPicking; - final isProcessing = provider.isProcessing; - final isReview = !isPicking && !isProcessing && provider.items.isNotEmpty; - - final firstSuccessIndex = isReview - ? provider.items.indexWhere((i) => i.status == FileImportStatus.success) - : -1; - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - if (isPicking) return; - await _handleCancel(); - }, - child: Padding( - padding: EdgeInsets.only( - left: Spacing.lg, - right: Spacing.lg, - top: Spacing.md, - bottom: widget.isDesktop - ? Spacing.lg - : viewInsets.bottom + Spacing.lg, - ), - child: LayoutBuilder( - builder: (context, constraints) { - // DraggableScrollableSheet passes unconstrained cross-axis width - // during its first layout frame on web. Return an empty widget for - // that transient frame to avoid BoxConstraints infinite width - // crashes in Material widgets like OutlinedButton. - if (!constraints.hasBoundedWidth) { - return const SizedBox.shrink(); - } - return ListView( - controller: widget.scrollController, - children: [ - if (!widget.isDesktop) ...[ - const BottomSheetHandle(), - const SizedBox(height: Spacing.lg), - ], - _buildHeader( - textTheme: textTheme, - colorScheme: colorScheme, - provider: provider, - isPicking: isPicking, - isProcessing: isProcessing, - isReview: isReview, - ), - const SizedBox(height: Spacing.md), - if (isPicking) - SizedBox( - height: 200, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: Spacing.md), - Text( - 'Opening file picker...', - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ) - else ...[ - for (var i = 0; i < provider.items.length; i++) ...[ - if (i > 0) const SizedBox(height: Spacing.sm), - FileImportItemCard( - item: provider.items[i], - index: i, - isReviewPhase: isReview, - isDesktop: widget.isDesktop, - initiallyExpanded: isReview && i == firstSuccessIndex, - onRemove: isReview ? (i) => provider.removeItem(i) : null, - ), - ], - const SizedBox(height: Spacing.md), - _buildFooter( - provider: provider, - isPicking: isPicking, - isProcessing: isProcessing, - isReview: isReview, - ), - ], - ], - ); - }, - ), - ), - ); - } - - // ============================================================================ - // HEADER - // ============================================================================ - - Widget _buildHeader({ - required TextTheme textTheme, - required ColorScheme colorScheme, - required AddBookProvider provider, - required bool isPicking, - required bool isProcessing, - required bool isReview, - }) { - if (isPicking) { - return Text('Import digital books', style: textTheme.headlineSmall); - } - - if (isProcessing) { - final totalCount = provider.totalCount; - final processedCount = provider.processedCount; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Importing $totalCount ${totalCount == 1 ? 'book' : 'books'}', - style: textTheme.headlineSmall, - ), - const SizedBox(height: Spacing.sm), - LinearProgressIndicator( - value: totalCount > 0 ? processedCount / totalCount : 0, - ), - const SizedBox(height: Spacing.sm), - Text( - '$processedCount of $totalCount processed', - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ); - } - - if (isReview) { - final successCount = provider.successCount; - final totalCount = provider.totalCount; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - successCount == 1 - ? 'Review imported book' - : 'Review imported books', - style: textTheme.headlineSmall, - ), - const SizedBox(height: Spacing.xs), - Text( - '$successCount of $totalCount ${totalCount == 1 ? 'book' : 'books'} ready to add', - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ); - } - - return Text('Import digital books', style: textTheme.headlineSmall); - } - - // ============================================================================ - // FOOTER - // ============================================================================ - - Widget _buildFooter({ - required AddBookProvider provider, - required bool isPicking, - required bool isProcessing, - required bool isReview, - }) { - // No buttons during picking - if (isPicking) return const SizedBox.shrink(); - - if (isProcessing) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton(onPressed: _handleCancel, child: const Text('Cancel')), - ], - ); - } - - if (isReview) { - final successCount = provider.successCount; - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: _handleCancel, child: const Text('Cancel')), - const SizedBox(width: Spacing.sm), - FilledButton( - onPressed: - provider.hasSuccessfulItems && !provider.isAddingToLibrary - ? () => _addToLibrary(provider) - : null, - child: provider.isAddingToLibrary - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - successCount == 1 - ? 'Add to library' - : 'Add $successCount books to library', - ), - ), - ], - ); - } - - return const SizedBox.shrink(); - } - - // ============================================================================ - // ACTIONS - // ============================================================================ - - Future _addToLibrary(AddBookProvider provider) async { - final dataStore = context.read(); - final count = await provider.addToLibrary(dataStore); - - if (!mounted) return; - - // Capture messenger before popping (context becomes invalid after pop) - final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); - - messenger.showSnackBar( - SnackBar( - content: Text( - count == 1 - ? 'Added 1 book to library' - : 'Added $count books to library', - ), - ), - ); - } -} 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/test/widgets/add_book/file_import_sheet_test.dart b/client/test/widgets/add_book/file_import_sheet_test.dart deleted file mode 100644 index 1509ff8..0000000 --- a/client/test/widgets/add_book/file_import_sheet_test.dart +++ /dev/null @@ -1,509 +0,0 @@ -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/models/book.dart'; -import 'package:papyrus/providers/add_book_provider.dart'; -import 'package:papyrus/widgets/add_book/file_import_item_card.dart'; -import 'package:papyrus/widgets/add_book/file_import_sheet.dart'; -import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:provider/provider.dart'; - -// --------------------------------------------------------------------------- -// Fake FilePicker for integration tests -// --------------------------------------------------------------------------- - -/// A fake [FilePicker] that returns a pre-configured result (or null). -/// -/// Uses [MockPlatformInterfaceMixin] to bypass the platform interface -/// token verification so it can be set via [FilePicker.platform]. -class FakeFilePicker extends FilePicker with MockPlatformInterfaceMixin { - FilePickerResult? resultToReturn; - - @override - Future pickFiles({ - String? dialogTitle, - String? initialDirectory, - FileType type = FileType.any, - List? allowedExtensions, - Function(FilePickerStatus)? onFileLoading, - bool allowCompression = true, - int compressionQuality = 30, - bool allowMultiple = false, - bool withData = false, - bool withReadStream = false, - bool lockParentWindow = false, - bool readSequential = false, - }) async { - return resultToReturn; - } -} - -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -/// Creates a [FileImportItem] with minimal test data. -FileImportItem _testItem({ - String fileName = 'test.epub', - FileImportStatus status = FileImportStatus.success, - String title = 'Test Book', - String author = 'Test Author', - String? errorMessage, -}) { - return FileImportItem( - fileName: fileName, - fileSize: 1024, - bytes: Uint8List(0), - format: BookFormat.epub, - status: status, - title: title, - author: author, - errorMessage: errorMessage, - ); -} - -/// Builds the [ImportContent] widget inside a mobile-like bottom sheet context. -/// -/// Uses [DraggableScrollableSheet] to reproduce the real constraints the widget -/// receives on mobile (scroll controller from the sheet, bounded height from the -/// sheet's fraction of screen). -Widget _buildMobileSheet({ - required AddBookProvider provider, - DataStore? dataStore, -}) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: provider), - ChangeNotifierProvider.value(value: dataStore ?? DataStore()), - ], - child: MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => SizedBox( - width: 420, - height: 800, - child: DraggableScrollableSheet( - initialChildSize: 0.9, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (context, scrollController) => ImportContent( - isDesktop: false, - scrollController: scrollController, - autoPick: false, - ), - ), - ), - ), - ), - ), - ); -} - -/// Builds the [ImportContent] widget inside a desktop-like dialog context. -/// -/// Uses [ConstrainedBox] to reproduce the real constraints from the -/// Dialog + ConstrainedBox wrapper used in [FileImportSheet.show]. -Widget _buildDesktopDialog({ - required AddBookProvider provider, - DataStore? dataStore, -}) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: provider), - ChangeNotifierProvider.value(value: dataStore ?? DataStore()), - ], - child: MaterialApp( - home: Scaffold( - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640, maxHeight: 600), - child: const Material( - child: ImportContent(isDesktop: true, autoPick: false), - ), - ), - ), - ), - ), - ); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -void main() { - group('FileImportSheet - mobile (DraggableScrollableSheet)', () { - testWidgets('picking state renders without exceptions', (tester) async { - final provider = AddBookProvider(); - provider.setTestState(isPicking: true); - - await tester.pumpWidget(_buildMobileSheet(provider: provider)); - await tester.pump(); - - // Should show the picking spinner and header - expect(find.text('Import digital books'), findsOneWidget); - expect(find.text('Opening file picker...'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - // Should show bottom sheet handle - expect(find.byType(BottomSheetHandle), findsOneWidget); - // No exceptions in the render tree - expect(tester.takeException(), isNull); - }); - - testWidgets('processing state renders items and cancel button', ( - tester, - ) async { - final provider = AddBookProvider(); - provider.setTestState( - isProcessing: true, - processedCount: 1, - items: [ - _testItem(status: FileImportStatus.success, fileName: 'book1.epub'), - _testItem( - status: FileImportStatus.extracting, - fileName: 'book2.epub', - ), - _testItem(status: FileImportStatus.pending, fileName: 'book3.epub'), - ], - ); - - await tester.pumpWidget(_buildMobileSheet(provider: provider)); - await tester.pump(); - - // Should show the progress header - expect(find.text('Importing 3 books'), findsOneWidget); - expect(find.text('1 of 3 processed'), findsOneWidget); - expect(find.byType(LinearProgressIndicator), findsOneWidget); - - // Scroll to cancel button to ensure it's reachable - await tester.scrollUntilVisible( - find.widgetWithText(OutlinedButton, 'Cancel'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - - // All 3 item cards should now be visible/built - expect( - find.byType(FileImportItemCard, skipOffstage: false), - findsNWidgets(3), - ); - - // Cancel button is now visible - expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); - - expect(tester.takeException(), isNull); - }); - - testWidgets('review state: footer buttons are reachable by scrolling', ( - tester, - ) async { - final provider = AddBookProvider(); - provider.setTestState( - items: [ - _testItem( - status: FileImportStatus.success, - title: 'Good Book', - author: 'Author A', - ), - _testItem(status: FileImportStatus.duplicate, fileName: 'dup.epub'), - _testItem( - status: FileImportStatus.error, - fileName: 'bad.epub', - errorMessage: 'Parse failed', - ), - ], - ); - - await tester.pumpWidget(_buildMobileSheet(provider: provider)); - await tester.pump(); - - // Should show review header - expect(find.text('Review imported book'), findsOneWidget); - expect(find.text('1 of 3 books ready to add'), findsOneWidget); - - // Scroll to the "Add to library" button to verify it's reachable - await tester.scrollUntilVisible( - find.widgetWithText(FilledButton, 'Add to library'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - - // Both footer buttons must be visible after scrolling - expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); - expect( - find.widgetWithText(FilledButton, 'Add to library'), - findsOneWidget, - ); - - expect(tester.takeException(), isNull); - }); - }); - - group('FileImportSheet - desktop (Dialog)', () { - testWidgets('picking state renders without exceptions', (tester) async { - final provider = AddBookProvider(); - provider.setTestState(isPicking: true); - - await tester.pumpWidget(_buildDesktopDialog(provider: provider)); - await tester.pump(); - - expect(find.text('Import digital books'), findsOneWidget); - expect(find.text('Opening file picker...'), findsOneWidget); - // No bottom sheet handle on desktop - expect(find.byType(BottomSheetHandle), findsNothing); - - expect(tester.takeException(), isNull); - }); - - testWidgets('processing state renders items and cancel button', ( - tester, - ) async { - final provider = AddBookProvider(); - provider.setTestState( - isProcessing: true, - processedCount: 0, - items: [ - _testItem(status: FileImportStatus.extracting, fileName: 'book1.pdf'), - _testItem(status: FileImportStatus.pending, fileName: 'book2.pdf'), - ], - ); - - await tester.pumpWidget(_buildDesktopDialog(provider: provider)); - await tester.pump(); - - expect(find.text('Importing 2 books'), findsOneWidget); - - // Scroll to cancel button to ensure it's reachable - await tester.scrollUntilVisible( - find.widgetWithText(OutlinedButton, 'Cancel'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - - expect( - find.byType(FileImportItemCard, skipOffstage: false), - findsNWidgets(2), - ); - expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); - - expect(tester.takeException(), isNull); - }); - - testWidgets('review state: footer buttons are reachable by scrolling', ( - tester, - ) async { - final provider = AddBookProvider(); - provider.setTestState( - items: [ - _testItem( - status: FileImportStatus.success, - title: 'Desktop Book', - author: 'Author B', - ), - ], - ); - - await tester.pumpWidget(_buildDesktopDialog(provider: provider)); - await tester.pump(); - - expect(find.text('Review imported book'), findsOneWidget); - expect(find.byType(FileImportItemCard), findsOneWidget); - - // Scroll to the "Add to library" button - await tester.scrollUntilVisible( - find.widgetWithText(FilledButton, 'Add to library'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - - expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); - expect( - find.widgetWithText(FilledButton, 'Add to library'), - findsOneWidget, - ); - - expect(tester.takeException(), isNull); - }); - - testWidgets('review with multiple successes shows correct button text', ( - tester, - ) async { - final provider = AddBookProvider(); - provider.setTestState( - items: [ - _testItem(status: FileImportStatus.success, title: 'Book 1'), - _testItem(status: FileImportStatus.success, title: 'Book 2'), - _testItem(status: FileImportStatus.success, title: 'Book 3'), - ], - ); - - await tester.pumpWidget(_buildDesktopDialog(provider: provider)); - await tester.pump(); - - expect(find.text('Review imported books'), findsOneWidget); - expect(find.text('3 of 3 books ready to add'), findsOneWidget); - - // Scroll to the multi-book button - await tester.scrollUntilVisible( - find.widgetWithText(FilledButton, 'Add 3 books to library'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - - expect( - find.widgetWithText(FilledButton, 'Add 3 books to library'), - findsOneWidget, - ); - - expect(tester.takeException(), isNull); - }); - }); - - // ========================================================================= - // Integration: FileImportSheet.show() through real showModalBottomSheet - // ========================================================================= - - group('FileImportSheet.show() integration', () { - late FakeFilePicker fakePicker; - - setUp(() { - fakePicker = FakeFilePicker(); - FilePicker.platform = fakePicker; - }); - - testWidgets('mobile: opens bottom sheet without rendering exceptions', ( - tester, - ) async { - // Picker returns null (user cancels) — sheet should open, show - // the picking state, then close automatically. - fakePicker.resultToReturn = null; - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: DataStore(), - child: MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => ElevatedButton( - onPressed: () => FileImportSheet.show(context), - child: const Text('Import'), - ), - ), - ), - ), - ), - ); - - // Tap the button to trigger show() - await tester.tap(find.text('Import')); - // Pump once to trigger the post-frame callback and open the sheet - await tester.pump(); - // Pump again for the sheet animation to begin - await tester.pump(); - - // The sheet should have opened and rendered the picking state. - // If DraggableScrollableSheet passes unconstrained width, a - // rendering exception would be caught here. - expect(tester.takeException(), isNull); - - // Let the picker future resolve (null → sheet auto-closes) - await tester.pumpAndSettle(); - - expect(tester.takeException(), isNull); - }); - - testWidgets('mobile: processes picked files without rendering exceptions', ( - tester, - ) async { - // Picker returns a file - fakePicker.resultToReturn = FilePickerResult([ - PlatformFile( - name: 'test-book.epub', - size: 100, - bytes: Uint8List.fromList([0x50, 0x4B, 0x03, 0x04]), - ), - ]); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: DataStore(), - child: MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => ElevatedButton( - onPressed: () => FileImportSheet.show(context), - child: const Text('Import'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Import')); - await tester.pump(); - await tester.pump(); - - // No rendering exception during the picking → processing transition - expect(tester.takeException(), isNull); - - // Pump several frames to let processing UI render - for (var i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - final exception = tester.takeException(); - if (exception != null) { - fail('Rendering exception on frame $i: $exception'); - } - } - }); - - testWidgets('desktop: opens dialog without rendering exceptions', ( - tester, - ) async { - fakePicker.resultToReturn = null; - - // Use a wide screen to trigger the desktop path (>= 840px) - tester.view.physicalSize = const Size(1200, 900); - tester.view.devicePixelRatio = 1.0; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: DataStore(), - child: MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => ElevatedButton( - onPressed: () => FileImportSheet.show(context), - child: const Text('Import'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Import')); - await tester.pump(); - await tester.pump(); - - expect(tester.takeException(), isNull); - - await tester.pumpAndSettle(); - - expect(tester.takeException(), isNull); - }); - }); -}