From 05489d9782bef023abf7a7561402bac2586abcf9 Mon Sep 17 00:00:00 2001 From: Eoic Date: Tue, 14 Jul 2026 21:21:12 +0300 Subject: [PATCH 1/6] docs: define add book backdrop continuity --- ...-14-add-book-backdrop-continuity-design.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md diff --git a/docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md b/docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md new file mode 100644 index 0000000..bf5e54c --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-add-book-backdrop-continuity-design.md @@ -0,0 +1,35 @@ +# Add Book Backdrop Continuity Design + +## Goal + +Prevent the modal backdrop from flashing when the add-book choice sheet transitions to the digital-book import sheet. + +## Root Cause + +The digital option currently pops the add-book choice `ModalBottomSheetRoute` and immediately pushes a new import `ModalBottomSheetRoute`. Each route owns a separate `ModalBarrier`, so the backdrop is removed and recreated while the two route animations overlap. The visible flash is a route-lifecycle artifact, not an import-content rendering issue. + +## Design + +Keep the existing add-book modal route mounted when the user selects **Import digital books**. The choice sheet will switch its body to the existing import content inside that route, preserving the same barrier and navigator overlay entry throughout the transition. + +`ImportBookSheet.show` will remain available for callers that open the import flow directly. Its content widget will be reusable by the choice sheet so import behavior, state, file handling, and layout stay in one implementation. + +The **Add physical book** path remains unchanged because it does not exhibit the reported flash and changing its large draggable form would expand the scope. + +## Transition Behavior + +- Opening **Add book** creates one modal route and one backdrop. +- Selecting **Import digital books** replaces only the sheet content; it does not pop or push a route. +- Canceling or dismissing the import content closes the existing modal route and returns to the library, matching current behavior. +- Direct calls to `ImportBookSheet.show` continue to open the same import UI in a modal bottom sheet. + +## Verification + +Add a widget regression test that opens the choice sheet, records the active modal barrier, selects **Import digital books**, and verifies: + +- the import content is displayed; +- exactly one modal barrier remains; +- the original barrier element remains mounted, proving it was not recreated; +- no additional popup route is pushed during the content transition. + +Run the focused add-book widget tests and static analysis for the modified files. From fe9ea22620234aa1b0eb8176ea56772bf9ce2923 Mon Sep 17 00:00:00 2001 From: Eoic Date: Tue, 14 Jul 2026 21:25:21 +0300 Subject: [PATCH 2/6] fix: preserve add book modal backdrop --- .../add_book/add_book_choice_sheet.dart | 75 +++++----- .../widgets/add_book/import_book_sheet.dart | 6 +- .../add_book/add_book_sheets_test.dart | 41 +++++- ...2026-07-14-add-book-backdrop-continuity.md | 133 ++++++++++++++++++ 4 files changed, 219 insertions(+), 36 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md diff --git a/app/lib/widgets/add_book/add_book_choice_sheet.dart b/app/lib/widgets/add_book/add_book_choice_sheet.dart index 264fdd9..301afb4 100644 --- a/app/lib/widgets/add_book/add_book_choice_sheet.dart +++ b/app/lib/widgets/add_book/add_book_choice_sheet.dart @@ -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. @@ -18,48 +18,57 @@ class AddBookChoiceSheet extends StatelessWidget { static Future 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 createState() => _AddBookChoiceSheetState(); +} + +class _AddBookChoiceSheetState extends State { + 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); + }, + ), + ], + ), ); } } diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 2a70479..e63b300 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -34,12 +34,14 @@ 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 const SingleChildScrollView(child: _ImportContent()); + } } class _ImportContent extends StatefulWidget { diff --git a/app/test/widgets/add_book/add_book_sheets_test.dart b/app/test/widgets/add_book/add_book_sheets_test.dart index 5c797f2..bba22c9 100644 --- a/app/test/widgets/add_book/add_book_sheets_test.dart +++ b/app/test/widgets/add_book/add_book_sheets_test.dart @@ -5,14 +5,29 @@ 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 route, Route? previousRoute) { + pushCount += 1; + super.didPush(route, previousRoute); + } +} + void main() { - Future pumpLauncher(WidgetTester tester, VoidCallback Function(BuildContext) action) async { + Future pumpLauncher( + WidgetTester tester, + VoidCallback Function(BuildContext) action, { + List 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')), @@ -71,4 +86,28 @@ 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); + }); } diff --git a/docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md b/docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md new file mode 100644 index 0000000..dbf20eb --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md @@ -0,0 +1,133 @@ +# Add Book Backdrop Continuity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the add-book modal backdrop mounted while transitioning from the choice sheet to the digital import UI. + +**Architecture:** Make the choice sheet own a small presentation state that swaps its body from the choice options to a reusable `ImportBookSheet` widget. Keep `ImportBookSheet.show` as the direct-entry modal wrapper, but move its scrollable content into `ImportBookSheet.build` so both entry paths share the same stateful import implementation. + +**Tech Stack:** Flutter, Dart, Material modal bottom sheets, Flutter widget tests + +--- + +### Task 1: Capture modal-backdrop continuity + +**Files:** +- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [x] **Step 1: Add a navigator observer and failing regression test** + +Add a `_CountingNavigatorObserver` that increments `pushCount` in `didPush`. Extend `pumpLauncher` with an optional `navigatorObservers` argument and pass it to `MaterialApp`. + +Add this test: + +```dart +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 initialPushCount = observer.pushCount; + final initialBarrier = tester.element(find.byType(ModalBarrier)); + + await tester.tap(find.text('Import digital books')); + await tester.pumpAndSettle(); + + expect(find.text('Import book'), findsOneWidget); + expect(find.byType(ModalBarrier), findsOneWidget); + expect(tester.element(find.byType(ModalBarrier)), same(initialBarrier)); + expect(observer.pushCount, initialPushCount); +}); +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "digital import transition preserves the modal backdrop" +``` + +Expected: FAIL because the current callback pops the choice route and pushes a second modal route, changing the barrier element and incrementing `pushCount`. + +### Task 2: Reuse import content within the choice route + +**Files:** +- Modify: `app/lib/widgets/add_book/add_book_choice_sheet.dart` +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` +- Test: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [x] **Step 1: Make `ImportBookSheet` render its reusable content** + +Change the direct modal builder to `const ImportBookSheet()` and implement `build` as: + +```dart +@override +Widget build(BuildContext context) { + return const SingleChildScrollView(child: _ImportContent()); +} +``` + +- [x] **Step 2: Keep the choice modal route and swap its content** + +Convert `AddBookChoiceSheet` to a `StatefulWidget`. Move the choice padding into the choice-state branch so the import content retains its existing layout. Store a `_showImport` boolean and change the digital option callback to `setState(() => _showImport = true)`. + +The state build method begins with: + +```dart +@override +Widget build(BuildContext context) { + if (_showImport) { + return const ImportBookSheet(); + } + + return Padding( + padding: const EdgeInsets.only( + left: Spacing.lg, + right: Spacing.lg, + top: Spacing.md, + bottom: Spacing.lg, + ), + child: _buildChoices(context), + ); +} +``` + +Keep the physical-book callback unchanged: it still pops the choice route and opens `AddPhysicalBookSheet` with `callerContext`. + +- [x] **Step 3: Format and run the focused test to verify GREEN** + +Run: + +```bash +dart format app/lib/widgets/add_book/add_book_choice_sheet.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "digital import transition preserves the modal backdrop" +``` + +Expected: PASS. + +- [x] **Step 4: Run complete focused verification** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart +flutter analyze lib/widgets/add_book/add_book_choice_sheet.dart lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart +``` + +Expected: all add-book sheet tests pass and analysis reports `No issues found!`. + +- [x] **Step 5: Commit the implementation** + +```bash +git add app/lib/widgets/add_book/add_book_choice_sheet.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart docs/superpowers/plans/2026-07-14-add-book-backdrop-continuity.md +git commit -m "fix: preserve add book modal backdrop" +``` From 8ab540e0245b34ccc9c5d3766c8adb44ae1bdc5d Mon Sep 17 00:00:00 2001 From: Eoic Date: Tue, 14 Jul 2026 21:49:26 +0300 Subject: [PATCH 3/6] docs: define import action button shapes --- ...7-14-import-action-button-shapes-design.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md diff --git a/docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md b/docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md new file mode 100644 index 0000000..dabbb7d --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-import-action-button-shapes-design.md @@ -0,0 +1,23 @@ +# Import Action Button Shapes Design + +## Goal + +Give the paired actions in the successful digital-book import state one consistent pill shape. + +## Current Behavior + +`Pick different file` is an `OutlinedButton` that inherits Papyrus's 8px rounded-rectangle shape. `Add to library` is a `FilledButton` that inherits Material 3's pill shape because the application theme does not define a filled-button shape. Their equal placement makes the mismatch visually prominent. + +## Design + +Apply an explicit `StadiumBorder` to both success-state buttons: + +- `Pick different file` remains an outlined secondary action. +- `Add to library` remains a filled primary action. +- Both buttons retain their equal widths, current spacing, labels, enabled and disabled behavior, and callbacks. + +The shape override is local to the successful import action row. It does not change global button themes or introduce a shared component. + +## Verification + +Extend the add-book sheet widget tests to render the successful import state and verify that the outlined and filled actions both resolve to `StadiumBorder`. Run the focused add-book sheet tests and static analysis for the modified files. From 4dd1e307512a6820c998597311e71f6a6d25a492 Mon Sep 17 00:00:00 2001 From: Eoic Date: Tue, 14 Jul 2026 21:54:34 +0300 Subject: [PATCH 4/6] fix: unify import action button shapes --- .../widgets/add_book/import_book_sheet.dart | 29 +++- .../add_book/add_book_sheets_test.dart | 24 +++ .../2026-07-14-import-action-button-shapes.md | 155 ++++++++++++++++++ 3 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-import-action-button-shapes.md diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index e63b300..a776bda 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -24,7 +24,12 @@ 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; + + @visibleForTesting + const ImportBookSheet.withInitialResult(this.initialResult, {super.key}); + + final BookImportResult? initialResult; /// Show the import sheet as a scrollable, content-sized bottom sheet. static Future show(BuildContext context) { @@ -40,12 +45,14 @@ class ImportBookSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return const SingleChildScrollView(child: _ImportContent()); + return SingleChildScrollView(child: _ImportContent(initialResult: initialResult)); } } class _ImportContent extends StatefulWidget { - const _ImportContent(); + const _ImportContent({this.initialResult}); + + final BookImportResult? initialResult; @override State<_ImportContent> createState() => _ImportContentState(); @@ -55,11 +62,18 @@ 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; + } + @override void dispose() { _importService.dispose(); @@ -345,12 +359,17 @@ class _ImportContentState extends State<_ImportContent> { _filename = null; }); }, + style: OutlinedButton.styleFrom(shape: const StadiumBorder()), 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()), + child: const Text('Add to library'), + ), ), ], ), diff --git a/app/test/widgets/add_book/add_book_sheets_test.dart b/app/test/widgets/add_book/add_book_sheets_test.dart index bba22c9..2c38396 100644 --- a/app/test/widgets/add_book/add_book_sheets_test.dart +++ b/app/test/widgets/add_book/add_book_sheets_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/services/book_import_result.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'; @@ -15,6 +16,16 @@ class _CountingNavigatorObserver extends NavigatorObserver { } } +const importedBook = BookImportResult( + bookId: 'book-1', + title: 'Frankenstein', + author: 'Mary Wollstonecraft Shelley', + pageCount: 239, + fileSize: 1024, + fileHash: 'hash', + fileExtension: 'epub', +); + void main() { Future pumpLauncher( WidgetTester tester, @@ -110,4 +121,17 @@ void main() { 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(find.widgetWithText(OutlinedButton, 'Pick different file')); + final addButton = tester.widget(find.widgetWithText(FilledButton, 'Add to library')); + + expect(pickButton.style?.shape?.resolve({}), isA()); + expect(addButton.style?.shape?.resolve({}), isA()); + }); } diff --git a/docs/superpowers/plans/2026-07-14-import-action-button-shapes.md b/docs/superpowers/plans/2026-07-14-import-action-button-shapes.md new file mode 100644 index 0000000..d058ecd --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-import-action-button-shapes.md @@ -0,0 +1,155 @@ +# Import Action Button Shapes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render the successful digital-import action pair with matching pill-shaped buttons. + +**Architecture:** Add a test-only initial-result constructor so the real import success UI can be rendered deterministically in a widget test. Then apply an explicit `StadiumBorder` to both success actions, keeping their hierarchy, layout, and callbacks unchanged. + +**Tech Stack:** Flutter, Dart, Material buttons, Flutter widget tests + +--- + +### Task 1: Make the real success state testable + +**Files:** +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:22-59` +- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [x] **Step 1: Add a failing success-state rendering test** + +Import `BookImportResult` and add this fixture: + +```dart +const importedBook = BookImportResult( + bookId: 'book-1', + title: 'Frankenstein', + author: 'Mary Wollstonecraft Shelley', + pageCount: 239, + fileSize: 1024, + fileHash: 'hash', + fileExtension: 'epub', +); +``` + +Add a widget test that pumps: + +```dart +MaterialApp( + home: Scaffold( + body: ImportBookSheet.withInitialResult(importedBook), + ), +) +``` + +and expects `Pick different file` and `Add to library` to be present. + +- [x] **Step 2: Run the test to verify RED** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "successful import actions can be rendered for widget verification" +``` + +Expected: FAIL to compile because `ImportBookSheet.withInitialResult` does not exist. + +- [x] **Step 3: Add the minimal test-only initial-result seam** + +Add an `@visibleForTesting` named constructor and nullable `initialResult` field to `ImportBookSheet`. Pass it into `_ImportContent`, then initialize `_state` and `_result` in `initState`: + +```dart +const ImportBookSheet({super.key}) : initialResult = null; + +@visibleForTesting +const ImportBookSheet.withInitialResult(this.initialResult, {super.key}); + +final BookImportResult? initialResult; +``` + +```dart +@override +void initState() { + super.initState(); + _result = widget.initialResult; + _state = _result == null ? _ImportState.idle : _ImportState.success; +} +``` + +- [x] **Step 4: Run the rendering test to verify GREEN** + +Run the command from Step 2. + +Expected: PASS with both real success actions rendered. + +### Task 2: Apply and verify matching pill shapes + +**Files:** +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:335-354` +- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [x] **Step 1: Extend the success-state test with failing shape assertions** + +Read both rendered button widgets and resolve their explicit shape styles: + +```dart +final pickButton = tester.widget( + find.widgetWithText(OutlinedButton, 'Pick different file'), +); +final addButton = tester.widget( + find.widgetWithText(FilledButton, 'Add to library'), +); + +expect(pickButton.style?.shape?.resolve({}), isA()); +expect(addButton.style?.shape?.resolve({}), isA()); +``` + +- [x] **Step 2: Run the test to verify RED** + +Run the command from Task 1, Step 2. + +Expected: FAIL because neither success action currently defines an explicit shape style. + +- [x] **Step 3: Apply explicit pill styles to both buttons** + +Add these local styles without changing any other properties: + +```dart +style: OutlinedButton.styleFrom(shape: const StadiumBorder()), +``` + +```dart +style: FilledButton.styleFrom(shape: const StadiumBorder()), +``` + +- [x] **Step 4: Format and verify GREEN** + +Run: + +```bash +dart format app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "successful import actions can be rendered for widget verification" +``` + +Expected: PASS. + +- [x] **Step 5: Run complete focused verification** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart +flutter analyze lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart +``` + +Expected: all add-book sheet tests pass and analysis reports `No issues found!`. + +- [x] **Step 6: Commit the implementation** + +```bash +git add app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart docs/superpowers/plans/2026-07-14-import-action-button-shapes.md +git commit -m "fix: unify import action button shapes" +``` From f69d7fe57e92b6eff6e6c5cf370a2d36483194e1 Mon Sep 17 00:00:00 2001 From: Eoic Date: Tue, 14 Jul 2026 22:07:58 +0300 Subject: [PATCH 5/6] docs: define import add loading state --- ...6-07-14-import-add-loading-state-design.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md diff --git a/docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md b/docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md new file mode 100644 index 0000000..19cd408 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-import-add-loading-state-design.md @@ -0,0 +1,37 @@ +# Import Add-to-Library Loading State Design + +## Goal + +Remove the distracting button-color pulse when a successfully imported digital book is added to the library, while providing clear progress feedback. + +## Root Cause + +Pressing **Add to library** sets `_committing` to true, which disables both actions and animates them into Material's disabled colors. The current `finally` block sets `_committing` back to false immediately before a successful sheet dismissal, so the buttons briefly animate toward their enabled colors again. The two state changes create the visible pulse. + +## Design + +During a commit: + +- Both actions remain disabled so duplicate commits and file changes are impossible. +- The outlined secondary action retains its normal foreground and border colors. +- The filled primary action retains its normal background and foreground colors. +- The primary button replaces `Add to library` with a compact progress indicator and the label `Adding...`. +- Button size, pill shape, spacing, and row layout remain unchanged. + +On success, `_committing` remains true until the modal sheet closes. This prevents the loading content from switching back before dismissal. On failure, `_committing` resets and the existing error state remains responsible for communicating the failure and retry action. + +## Accessibility + +Both buttons use `onPressed: null` during the commit, preserving correct disabled semantics for assistive technology. Progress is communicated with visible text as well as an indeterminate indicator, so it does not rely on color or animation alone. + +## Verification + +Extend the existing successful-import widget-test seam with an initial committing state. Verify that: + +- both action callbacks are disabled; +- the primary action shows `Adding...` and a progress indicator; +- the primary disabled colors resolve to the active primary colors; +- the secondary disabled foreground and border resolve to their active visual colors; +- `Add to library` is not displayed during the commit. + +Run the focused add-book sheet tests and static analysis for the modified files. From f5b502f6d26a0b4618959711aaf4340a289fbfa1 Mon Sep 17 00:00:00 2001 From: Eoic Date: Tue, 14 Jul 2026 22:12:15 +0300 Subject: [PATCH 6/6] fix: stabilize import commit loading state --- .../widgets/add_book/import_book_sheet.dart | 46 +++-- .../add_book/add_book_sheets_test.dart | 27 +++ .../2026-07-14-import-add-loading-state.md | 175 ++++++++++++++++++ 3 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-import-add-loading-state.md diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index a776bda..f6a0349 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -24,12 +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}) : initialResult = null; + const ImportBookSheet({super.key}) : initialResult = null, initialCommitting = false; @visibleForTesting - const ImportBookSheet.withInitialResult(this.initialResult, {super.key}); + 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 show(BuildContext context) { @@ -45,14 +46,17 @@ class ImportBookSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView(child: _ImportContent(initialResult: initialResult)); + return SingleChildScrollView( + child: _ImportContent(initialResult: initialResult, initialCommitting: initialCommitting), + ); } } class _ImportContent extends StatefulWidget { - const _ImportContent({this.initialResult}); + const _ImportContent({this.initialResult, this.initialCommitting = false}); final BookImportResult? initialResult; + final bool initialCommitting; @override State<_ImportContent> createState() => _ImportContentState(); @@ -72,6 +76,7 @@ class _ImportContentState extends State<_ImportContent> { super.initState(); _result = widget.initialResult; _state = _result == null ? _ImportState.idle : _ImportState.success; + _committing = widget.initialCommitting; } @override @@ -98,7 +103,7 @@ class _ImportContentState extends State<_ImportContent> { } bool _picking = false; - bool _committing = false; + late bool _committing; Future _pickAndProcess() async { if (_picking || _committing) return; @@ -200,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; @@ -359,7 +361,11 @@ class _ImportContentState extends State<_ImportContent> { _filename = null; }); }, - style: OutlinedButton.styleFrom(shape: const StadiumBorder()), + style: OutlinedButton.styleFrom( + shape: const StadiumBorder(), + disabledForegroundColor: colorScheme.primary, + side: BorderSide(color: colorScheme.outline, width: BorderWidths.thin), + ), child: const Text('Pick different file'), ), ), @@ -367,8 +373,24 @@ class _ImportContentState extends State<_ImportContent> { Expanded( child: FilledButton( onPressed: _committing ? null : _addToLibrary, - style: FilledButton.styleFrom(shape: const StadiumBorder()), - child: const Text('Add to library'), + 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'), ), ), ], diff --git a/app/test/widgets/add_book/add_book_sheets_test.dart b/app/test/widgets/add_book/add_book_sheets_test.dart index 2c38396..136db35 100644 --- a/app/test/widgets/add_book/add_book_sheets_test.dart +++ b/app/test/widgets/add_book/add_book_sheets_test.dart @@ -1,6 +1,7 @@ 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'; @@ -134,4 +135,30 @@ void main() { expect(pickButton.style?.shape?.resolve({}), isA()); expect(addButton.style?.shape?.resolve({}), isA()); }); + + 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(find.widgetWithText(OutlinedButton, 'Pick different file')); + final addButton = tester.widget(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.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); + }); } diff --git a/docs/superpowers/plans/2026-07-14-import-add-loading-state.md b/docs/superpowers/plans/2026-07-14-import-add-loading-state.md new file mode 100644 index 0000000..1371163 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-import-add-loading-state.md @@ -0,0 +1,175 @@ +# Import Add-to-Library Loading State Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the add-to-library button color pulse with a stable, accessible `Adding...` loading state. + +**Architecture:** Extend the existing test-only successful-import constructor so widget tests can render the real committing state. Keep both buttons disabled during the commit, override only their disabled colors to match their active visuals, show progress inside the primary action, and avoid resetting the successful commit state before dismissing the sheet. + +**Tech Stack:** Flutter, Dart, Material buttons, Flutter widget tests + +--- + +### Task 1: Render the committing success state in tests + +**Files:** +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:25-75` +- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [x] **Step 1: Add a failing committing-state test** + +Add a widget test that pumps: + +```dart +MaterialApp( + theme: AppTheme.dark, + home: const Scaffold( + body: ImportBookSheet.withInitialResult( + importedBook, + initialCommitting: true, + ), + ), +) +``` + +Read the `Pick different file` and primary filled buttons, then expect both `onPressed` callbacks to be null. + +- [x] **Step 2: Run the test to verify RED** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "committing import actions stay visually stable and show progress" +``` + +Expected: FAIL to compile because `initialCommitting` is not defined. + +- [x] **Step 3: Add the minimal initial-committing test seam** + +Give `ImportBookSheet`, `_ImportContent`, and `_ImportContentState` an initial committing value: + +```dart +const ImportBookSheet({super.key}) + : initialResult = null, + initialCommitting = false; + +@visibleForTesting +const ImportBookSheet.withInitialResult( + this.initialResult, { + this.initialCommitting = false, + super.key, +}); + +final bool initialCommitting; +``` + +Pass the value into `_ImportContent`, declare `_committing` as `late`, and assign `widget.initialCommitting` in `initState`. + +- [x] **Step 4: Run the test to verify the seam is GREEN** + +Run the command from Step 2. + +Expected: PASS for the disabled-callback assertions. + +### Task 2: Keep colors stable and show progress + +**Files:** +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart:153-215, 349-375` +- Modify: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [x] **Step 1: Add failing loading-content and disabled-color assertions** + +Extend the committing-state test: + +```dart +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.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); +``` + +- [x] **Step 2: Run the test to verify RED** + +Run the command from Task 1, Step 2. + +Expected: FAIL because the primary button still displays `Add to library` and the explicit disabled colors are absent. + +- [x] **Step 3: Implement the stable loading visuals** + +Use the success state's `colorScheme` to add: + +```dart +disabledForegroundColor: colorScheme.primary, +side: BorderSide(color: colorScheme.outline, width: BorderWidths.thin), +``` + +to the outlined button style, and: + +```dart +disabledBackgroundColor: colorScheme.primary, +disabledForegroundColor: colorScheme.onPrimary, +``` + +to the filled button style. + +When `_committing` is true, replace the primary label with: + +```dart +Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ), + const SizedBox(width: Spacing.sm), + const Text('Adding...'), + ], +) +``` + +- [x] **Step 4: Preserve the loading state until successful dismissal** + +Reset `_committing` inside the error handler together with the error state. Remove the unconditional successful reset from `finally`, so a successful commit dismisses the modal while still showing `Adding...`. + +- [x] **Step 5: Format and verify GREEN** + +Run: + +```bash +dart format app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "committing import actions stay visually stable and show progress" +``` + +Expected: PASS. + +- [x] **Step 6: Run complete focused verification** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart +flutter analyze lib/widgets/add_book/import_book_sheet.dart test/widgets/add_book/add_book_sheets_test.dart +``` + +Expected: all add-book sheet tests pass and analysis reports `No issues found!`. + +- [x] **Step 7: Commit the implementation** + +```bash +git add app/lib/widgets/add_book/import_book_sheet.dart app/test/widgets/add_book/add_book_sheets_test.dart docs/superpowers/plans/2026-07-14-import-add-loading-state.md +git commit -m "fix: stabilize import commit loading state" +```