Skip to content
Merged
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
75 changes: 42 additions & 33 deletions app/lib/widgets/add_book/add_book_choice_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:papyrus/widgets/add_book/import_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 {
class AddBookChoiceSheet extends StatefulWidget {
const AddBookChoiceSheet({required this.callerContext, super.key});

/// The context of the page that opened this sheet.
Expand All @@ -18,48 +18,57 @@ class AddBookChoiceSheet extends StatelessWidget {
static Future<void> show(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))),
builder: (_) => Padding(
padding: const EdgeInsets.only(left: Spacing.lg, right: Spacing.lg, top: Spacing.md, bottom: Spacing.lg),
child: AddBookChoiceSheet(callerContext: context),
),
builder: (_) => AddBookChoiceSheet(callerContext: context),
);
}

@override
State<AddBookChoiceSheet> createState() => _AddBookChoiceSheetState();
}

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

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

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

return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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, AZW3, MOBI, CBZ/CBR',
onTap: () {
Navigator.of(context).pop();
ImportBookSheet.show(callerContext);
},
),
const SizedBox(height: Spacing.sm),
_ChoiceOption(
icon: Icons.menu_book,
title: 'Add physical book',
subtitle: 'Enter details manually',
onTap: () {
Navigator.of(context).pop();
AddPhysicalBookSheet.show(callerContext);
},
),
],
return Padding(
padding: const EdgeInsets.only(left: Spacing.lg, right: Spacing.lg, top: Spacing.md, bottom: Spacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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, AZW3, MOBI, CBZ/CBR',
onTap: () => setState(() => _showImport = true),
),
const SizedBox(height: Spacing.sm),
_ChoiceOption(
icon: Icons.menu_book,
title: 'Add physical book',
subtitle: 'Enter details manually',
onTap: () {
Navigator.of(context).pop();
AddPhysicalBookSheet.show(widget.callerContext);
},
),
],
),
);
}
}
Expand Down
65 changes: 54 additions & 11 deletions app/lib/widgets/add_book/import_book_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ enum _ImportState { idle, processing, success, error }
/// Opens a file picker, processes the file in a Web Worker,
/// previews the extracted metadata, and adds the book to the library.
class ImportBookSheet extends StatelessWidget {
const ImportBookSheet({super.key});
const ImportBookSheet({super.key}) : initialResult = null, initialCommitting = false;

@visibleForTesting
const ImportBookSheet.withInitialResult(this.initialResult, {this.initialCommitting = false, super.key});

final BookImportResult? initialResult;
final bool initialCommitting;

/// Show the import sheet as a scrollable, content-sized bottom sheet.
static Future<void> show(BuildContext context) {
Expand All @@ -34,16 +40,23 @@ class ImportBookSheet extends StatelessWidget {
useRootNavigator: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))),
builder: (_) => const SingleChildScrollView(child: _ImportContent()),
builder: (_) => const ImportBookSheet(),
);
}

@override
Widget build(BuildContext context) => const SizedBox.shrink();
Widget build(BuildContext context) {
return SingleChildScrollView(
child: _ImportContent(initialResult: initialResult, initialCommitting: initialCommitting),
);
}
}

class _ImportContent extends StatefulWidget {
const _ImportContent();
const _ImportContent({this.initialResult, this.initialCommitting = false});

final BookImportResult? initialResult;
final bool initialCommitting;

@override
State<_ImportContent> createState() => _ImportContentState();
Expand All @@ -53,11 +66,19 @@ class _ImportContentState extends State<_ImportContent> {
static const double _pendingContentHeight = 232;

final _importService = BookImportService();
_ImportState _state = _ImportState.idle;
late _ImportState _state;
String? _filename;
String? _errorMessage;
BookImportResult? _result;

@override
void initState() {
super.initState();
_result = widget.initialResult;
_state = _result == null ? _ImportState.idle : _ImportState.success;
_committing = widget.initialCommitting;
}

@override
void dispose() {
_importService.dispose();
Expand All @@ -82,7 +103,7 @@ class _ImportContentState extends State<_ImportContent> {
}

bool _picking = false;
bool _committing = false;
late bool _committing;

Future<void> _pickAndProcess() async {
if (_picking || _committing) return;
Expand Down Expand Up @@ -184,13 +205,10 @@ class _ImportContentState extends State<_ImportContent> {
} catch (error) {
if (!mounted) return;
setState(() {
_committing = false;
_state = _ImportState.error;
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() => _committing = false);
}
}

if (!mounted || committedBook == null) return;
Expand Down Expand Up @@ -343,12 +361,37 @@ class _ImportContentState extends State<_ImportContent> {
_filename = null;
});
},
style: OutlinedButton.styleFrom(
shape: const StadiumBorder(),
disabledForegroundColor: colorScheme.primary,
side: BorderSide(color: colorScheme.outline, width: BorderWidths.thin),
),
child: const Text('Pick different file'),
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: FilledButton(onPressed: _committing ? null : _addToLibrary, child: const Text('Add to library')),
child: FilledButton(
onPressed: _committing ? null : _addToLibrary,
style: FilledButton.styleFrom(
shape: const StadiumBorder(),
disabledBackgroundColor: colorScheme.primary,
disabledForegroundColor: colorScheme.onPrimary,
),
child: _committing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary),
),
const SizedBox(width: Spacing.sm),
const Text('Adding...'),
],
)
: const Text('Add to library'),
),
),
],
),
Expand Down
92 changes: 91 additions & 1 deletion app/test/widgets/add_book/add_book_sheets_test.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:papyrus/services/book_import_result.dart';
import 'package:papyrus/themes/app_theme.dart';
import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart';
import 'package:papyrus/widgets/add_book/import_book_sheet.dart';
import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart';
import 'package:papyrus/widgets/shared/bottom_sheet_header.dart';

class _CountingNavigatorObserver extends NavigatorObserver {
int pushCount = 0;

@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
pushCount += 1;
super.didPush(route, previousRoute);
}
}

const importedBook = BookImportResult(
bookId: 'book-1',
title: 'Frankenstein',
author: 'Mary Wollstonecraft Shelley',
pageCount: 239,
fileSize: 1024,
fileHash: 'hash',
fileExtension: 'epub',
);

void main() {
Future<void> pumpLauncher(WidgetTester tester, VoidCallback Function(BuildContext) action) async {
Future<void> pumpLauncher(
WidgetTester tester,
VoidCallback Function(BuildContext) action, {
List<NavigatorObserver> navigatorObservers = const [],
}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1400, 1000);
addTearDown(tester.view.reset);

await tester.pumpWidget(
MaterialApp(
navigatorObservers: navigatorObservers,
home: Builder(
builder: (context) => Scaffold(
body: FilledButton(onPressed: action(context), child: const Text('Open')),
Expand Down Expand Up @@ -71,4 +98,67 @@ void main() {
findsNothing,
);
});

testWidgets('digital import transition preserves the modal backdrop', (tester) async {
final observer = _CountingNavigatorObserver();
await pumpLauncher(
tester,
(context) =>
() => AddBookChoiceSheet.show(context),
navigatorObservers: [observer],
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();

final dimmingBarrier = find.byWidgetPredicate((widget) => widget is ModalBarrier && widget.color != null);
final initialPushCount = observer.pushCount;
final initialBarrier = tester.element(dimmingBarrier);

await tester.tap(find.text('Import digital books'));
await tester.pumpAndSettle();

expect(find.text('Import book'), findsOneWidget);
expect(dimmingBarrier, findsOneWidget);
expect(tester.element(dimmingBarrier), same(initialBarrier));
expect(observer.pushCount, initialPushCount);
});

testWidgets('successful import actions can be rendered for widget verification', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: ImportBookSheet.withInitialResult(importedBook))));

expect(find.text('Pick different file'), findsOneWidget);
expect(find.text('Add to library'), findsOneWidget);

final pickButton = tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'Pick different file'));
final addButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Add to library'));

expect(pickButton.style?.shape?.resolve({}), isA<StadiumBorder>());
expect(addButton.style?.shape?.resolve({}), isA<StadiumBorder>());
});

testWidgets('committing import actions stay visually stable and show progress', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark,
home: const Scaffold(body: ImportBookSheet.withInitialResult(importedBook, initialCommitting: true)),
),
);

final pickButton = tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'Pick different file'));
final addButton = tester.widget<FilledButton>(find.byType(FilledButton));

expect(pickButton.onPressed, isNull);
expect(addButton.onPressed, isNull);
expect(find.text('Adding...'), findsOneWidget);
expect(find.text('Add to library'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);

final colorScheme = Theme.of(tester.element(find.text('Adding...'))).colorScheme;
const disabled = <WidgetState>{WidgetState.disabled};

expect(pickButton.style?.foregroundColor?.resolve(disabled), colorScheme.primary);
expect(pickButton.style?.side?.resolve(disabled)?.color, colorScheme.outline);
expect(addButton.style?.backgroundColor?.resolve(disabled), colorScheme.primary);
expect(addButton.style?.foregroundColor?.resolve(disabled), colorScheme.onPrimary);
});
}
Loading
Loading