Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions app/lib/widgets/add_book/add_book_choice_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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/add_book/import_book_sheet.dart';
import 'package:papyrus/widgets/add_book/book_import_sheet.dart';
import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart';

/// Choice sheet for selecting digital import, physical entry, or optional online search.
Expand All @@ -15,7 +15,13 @@ class AddBookChoiceSheet extends StatefulWidget {
final VoidCallback? onFindOnline;

/// Show the choice sheet as a modal bottom sheet.
static Future<void> show(BuildContext context, {VoidCallback? onFindOnline}) async {
static Future<void> show(
BuildContext context, {
VoidCallback? onFindOnline,
DigitalBookFilePicker? digitalFilePicker,
BookImportProcessor? bookImportProcessor,
ImportedBookFileDeleter? deleteImportedBookFile,
}) async {
Future<_AddBookChoice?>? sheetCompleted;
final choice = await showModalBottomSheet<_AddBookChoice>(
context: context,
Expand All @@ -40,8 +46,15 @@ class AddBookChoiceSheet extends StatefulWidget {
}

switch (choice) {
case _AddBookChoice.importDigital:
await BookImportSheet.show(
context,
pickFiles: digitalFilePicker,
processor: bookImportProcessor,
deleteBookFile: deleteImportedBookFile,
);
case _AddBookChoice.addPhysical:
AddPhysicalBookSheet.show(context);
await AddPhysicalBookSheet.show(context);
case _AddBookChoice.findOnline:
onFindOnline?.call();
}
Expand All @@ -53,7 +66,6 @@ class AddBookChoiceSheet extends StatefulWidget {

class _AddBookChoiceSheetState extends State<AddBookChoiceSheet> {
bool _isSelecting = false;
bool _showImport = false;

void _select(_AddBookChoice choice) {
if (_isSelecting) {
Expand All @@ -66,10 +78,6 @@ class _AddBookChoiceSheetState extends State<AddBookChoiceSheet> {

@override
Widget build(BuildContext context) {
if (_showImport) {
return const ImportBookSheet();
}

final textTheme = Theme.of(context).textTheme;

return Column(
Expand All @@ -84,7 +92,7 @@ class _AddBookChoiceSheetState extends State<AddBookChoiceSheet> {
icon: Icons.upload_file,
title: 'Import digital books',
subtitle: 'EPUB, PDF, AZW3, MOBI, CBZ/CBR',
onTap: () => setState(() => _showImport = true),
onTap: () => _select(_AddBookChoice.importDigital),
),
const SizedBox(height: Spacing.sm),
_ChoiceOption(
Expand All @@ -107,7 +115,7 @@ class _AddBookChoiceSheetState extends State<AddBookChoiceSheet> {
}
}

enum _AddBookChoice { addPhysical, findOnline }
enum _AddBookChoice { importDigital, addPhysical, findOnline }

class _ChoiceOption extends StatelessWidget {
final IconData icon;
Expand Down
81 changes: 81 additions & 0 deletions app/lib/widgets/add_book/add_book_sheet_scaffold.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:papyrus/themes/design_tokens.dart';
import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart';

/// Lays out an add-book sheet with fixed header and footer regions.
class AddBookSheetScaffold extends StatelessWidget {
final String title;
final VoidCallback onClose;
final Widget body;
final Widget footer;
final bool canClose;

const AddBookSheetScaffold({
super.key,
required this.title,
required this.onClose,
required this.body,
required this.footer,
this.canClose = true,
});

@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;

return LayoutBuilder(
builder: (context, constraints) {
final isCompactHeight = constraints.maxHeight < 280;
final verticalPadding = isCompactHeight ? 0.0 : Spacing.md;
final handleSpacing = isCompactHeight ? 0.0 : Spacing.lg;

return Column(
children: [
Container(
key: const Key('add-book-sheet-header'),
padding: EdgeInsets.fromLTRB(Spacing.lg, verticalPadding, Spacing.lg, verticalPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const BottomSheetHandle(),
SizedBox(height: handleSpacing),
Row(
children: [
Expanded(
child: Text(
title,
maxLines: isCompactHeight ? 1 : 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
textScaler: isCompactHeight ? TextScaler.noScaling : null,
),
),
IconButton(
constraints: isCompactHeight ? const BoxConstraints.tightFor(width: 44, height: 44) : null,
icon: const Icon(Icons.close),
padding: isCompactHeight ? EdgeInsets.zero : null,
tooltip: 'Close',
onPressed: canClose ? onClose : null,
),
],
),
],
),
),
const Divider(height: 1),
Expanded(child: body),
Container(
key: const Key('add-book-sheet-footer'),
padding: EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: verticalPadding),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(top: BorderSide(color: colorScheme.outlineVariant)),
),
child: SafeArea(top: false, child: footer),
),
],
);
},
);
}
}
98 changes: 41 additions & 57 deletions app/lib/widgets/add_book/add_physical_book_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import 'package:papyrus/models/book.dart';
import 'package:papyrus/services/metadata_service.dart';
import 'package:papyrus/themes/design_tokens.dart';
import 'package:papyrus/utils/image_utils.dart';
import 'package:papyrus/widgets/add_book/add_book_sheet_scaffold.dart';
import 'package:papyrus/widgets/add_book/isbn_scanner_dialog.dart';
import 'package:papyrus/widgets/book_edit/cover_image_picker.dart';
import 'package:papyrus/widgets/book_form/book_date_field.dart';
import 'package:papyrus/widgets/book_form/book_text_field.dart';
import 'package:papyrus/widgets/book_form/co_author_editor.dart';
import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart';
import 'package:papyrus/widgets/shared/bottom_sheet_header.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';

Expand Down Expand Up @@ -269,66 +268,51 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> {
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Form(
key: _formKey,
child: Column(
children: [
// Fixed header
Padding(
padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0),
child: Column(
children: [
const BottomSheetHandle(),
const SizedBox(height: Spacing.md),
BottomSheetHeader(
title: 'Add physical book',
onCancel: () => Navigator.pop(context),
onSave: _onSave,
saveLabel: 'Add',
canSave: _canSave,
),
],
),
),
const SizedBox(height: Spacing.md),
const Divider(height: 1),

// Scrollable form content
Expanded(
child: ListView(
controller: widget.scrollController,
padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.md),
child: AddBookSheetScaffold(
title: 'Add physical book',
onClose: () => Navigator.of(context).pop(),
body: ListView(
controller: widget.scrollController,
padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.md),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
margin: const EdgeInsets.only(bottom: Spacing.xs),
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: CoverImagePicker(
initialUrl: _coverUrl,
initialBytes: _coverImageBytes,
onUrlChanged: (url) => setState(() => _coverUrl = url),
onFileChanged: (bytes) => setState(() {
_coverImageBytes = bytes;
if (bytes != null) _coverUrl = null;
}),
coverWidth: 240,
),
),
Card(
margin: const EdgeInsets.only(bottom: Spacing.xs),
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: CoverImagePicker(
initialUrl: _coverUrl,
initialBytes: _coverImageBytes,
onUrlChanged: (url) => setState(() => _coverUrl = url),
onFileChanged: (bytes) => setState(() {
_coverImageBytes = bytes;
if (bytes != null) _coverUrl = null;
}),
coverWidth: 240,
),

_buildIsbnSection(),
_buildBasicInfoSection(),
_buildPublicationSection(),
_buildIdentifiersSection(),
_buildSeriesSection(),
_buildPhysicalBookSection(),
],
),
),

_buildIsbnSection(),
_buildBasicInfoSection(),
_buildPublicationSection(),
_buildIdentifiersSection(),
_buildSeriesSection(),
_buildPhysicalBookSection(),
],
),
),
],
],
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
const SizedBox(width: Spacing.sm),
FilledButton(onPressed: _canSave ? _onSave : null, child: const Text('Add')),
],
),
),
),
);
Expand Down
113 changes: 113 additions & 0 deletions app/lib/widgets/add_book/book_import_batch_item.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import 'package:flutter/foundation.dart';
import 'package:papyrus/services/book_import_result.dart';

@immutable
class SelectedBookFile {
const SelectedBookFile({required this.name, required this.bytes});

final String name;

/// The import workflow takes ownership of these bytes; callers must not
/// mutate them after creating this file selection.
final Uint8List? bytes;
}

enum BookImportBatchStatus { queued, processing, ready, processingFailed, adding, added, commitFailed }

@immutable
class BookImportBatchItem {
const BookImportBatchItem._({
required this.id,
required this.file,
required this.status,
this.result,
this.errorMessage,
});

factory BookImportBatchItem.queued({required String id, required SelectedBookFile file}) {
return BookImportBatchItem._(id: id, file: file, status: BookImportBatchStatus.queued);
}

final String id;
final SelectedBookFile file;
final BookImportBatchStatus status;
final BookImportResult? result;
final String? errorMessage;

bool get canRetry => status == BookImportBatchStatus.processingFailed || status == BookImportBatchStatus.commitFailed;

bool get isSettled => status != BookImportBatchStatus.queued && status != BookImportBatchStatus.processing;

bool get hasTemporaryFile => result != null && status != BookImportBatchStatus.added;

BookImportBatchItem startProcessing() {
if (status != BookImportBatchStatus.queued && status != BookImportBatchStatus.processingFailed) {
throw StateError('Cannot start processing an item with status $status.');
}

return BookImportBatchItem._(id: id, file: file, status: BookImportBatchStatus.processing);
}

BookImportBatchItem processingSucceeded(BookImportResult value) {
if (status != BookImportBatchStatus.processing) {
throw StateError('Cannot complete processing for an item with status $status.');
}

return BookImportBatchItem._(id: id, file: file, status: BookImportBatchStatus.ready, result: value);
}

BookImportBatchItem processingFailed(String message) {
if (status != BookImportBatchStatus.processing) {
throw StateError('Cannot fail processing for an item with status $status.');
}

return BookImportBatchItem._(
id: id,
file: file,
status: BookImportBatchStatus.processingFailed,
errorMessage: message,
);
}

BookImportBatchItem startAdding() {
if (status != BookImportBatchStatus.ready && status != BookImportBatchStatus.commitFailed) {
throw StateError('Cannot start adding an item with status $status.');
}
final value = result;
if (value == null) {
throw StateError('Cannot add an item without a processed result.');
}

return BookImportBatchItem._(id: id, file: file, status: BookImportBatchStatus.adding, result: value);
}

BookImportBatchItem added() {
if (status != BookImportBatchStatus.adding) {
throw StateError('Cannot finish adding an item with status $status.');
}
final value = result;
if (value == null) {
throw StateError('Cannot finish adding an item without a processed result.');
}

return BookImportBatchItem._(id: id, file: file, status: BookImportBatchStatus.added, result: value);
}

BookImportBatchItem commitFailed(String message) {
if (status != BookImportBatchStatus.adding) {
throw StateError('Cannot fail adding an item with status $status.');
}
final value = result;
if (value == null) {
throw StateError('Cannot fail adding an item without a processed result.');
}

return BookImportBatchItem._(
id: id,
file: file,
status: BookImportBatchStatus.commitFailed,
result: value,
errorMessage: message,
);
}
}
Loading
Loading