From d00c0a4c1f1372158a14537a404be6fc9b0c8951 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 00:26:32 +0300 Subject: [PATCH 01/12] feat: enhance GoalsPage layout to display empty state and improve goal visibility --- app/lib/pages/goals_page.dart | 202 +++++++++++++--------------- app/test/pages/goals_page_test.dart | 27 ++++ 2 files changed, 121 insertions(+), 108 deletions(-) create mode 100644 app/test/pages/goals_page_test.dart diff --git a/app/lib/pages/goals_page.dart b/app/lib/pages/goals_page.dart index 99fc6de..fb0e8ae 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -9,6 +9,7 @@ import 'package:papyrus/widgets/goals/completed_goal_chip.dart'; import 'package:papyrus/widgets/goals/goal_card.dart'; import 'package:papyrus/widgets/statistics/stat_card.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/empty_state.dart'; /// Goals page displaying reading goals with progress tracking. /// @@ -82,6 +83,7 @@ class _GoalsPageState extends State { Widget _buildMobileLayout(BuildContext context, GoalsProvider provider) { final textTheme = Theme.of(context).textTheme; + final hasGoals = provider.hasActiveGoals || provider.hasCompletedGoals; return Scaffold( floatingActionButton: FloatingActionButton( @@ -89,54 +91,54 @@ class _GoalsPageState extends State { child: const Icon(Icons.add), ), body: SafeArea( - child: RefreshIndicator( - onRefresh: provider.refresh, - child: ListView( - padding: const EdgeInsets.all(Spacing.md), - children: [ - // Stats summary row - if (provider.hasActiveGoals || provider.hasCompletedGoals) ...[ - _buildStatsRow(provider, isDesktop: false), - const SizedBox(height: Spacing.lg), - ], - // Active goals section - if (provider.hasActiveGoals) ...[ - Text('Active goals', style: textTheme.titleMedium), - const SizedBox(height: Spacing.sm), - ...provider.activeGoals.map( - (goal) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: GoalCard(goal: goal, onTap: () => _showGoalDetails(context, goal)), - ), + child: hasGoals + ? RefreshIndicator( + onRefresh: provider.refresh, + child: ListView( + padding: const EdgeInsets.all(Spacing.md), + children: [ + // Stats summary row + if (provider.hasActiveGoals || provider.hasCompletedGoals) ...[ + _buildStatsRow(provider, isDesktop: false), + const SizedBox(height: Spacing.lg), + ], + // Active goals section + if (provider.hasActiveGoals) ...[ + Text('Active goals', style: textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + ...provider.activeGoals.map( + (goal) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: GoalCard(goal: goal, onTap: () => _showGoalDetails(context, goal)), + ), + ), + ], + // Completed goals section + if (provider.hasCompletedGoals) ...[ + const SizedBox(height: Spacing.lg), + _buildCompletedHeader(context, provider.completedGoals.length), + const SizedBox(height: Spacing.sm), + if (!_completedCollapsed) ...[ + Opacity( + opacity: 0.6, + child: Column( + children: provider.completedGoals + .map( + (goal) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: GoalCard(goal: goal, onTap: () => _showGoalDetails(context, goal)), + ), + ) + .toList(), + ), + ), + ], + ], + const SizedBox(height: Spacing.lg), + ], ), - ], - // Empty state - if (!provider.hasActiveGoals) _buildEmptyState(context), - // Completed goals section - if (provider.hasCompletedGoals) ...[ - const SizedBox(height: Spacing.lg), - _buildCompletedHeader(context, provider.completedGoals.length), - const SizedBox(height: Spacing.sm), - if (!_completedCollapsed) ...[ - Opacity( - opacity: 0.6, - child: Column( - children: provider.completedGoals - .map( - (goal) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: GoalCard(goal: goal, onTap: () => _showGoalDetails(context, goal)), - ), - ) - .toList(), - ), - ), - ], - ], - const SizedBox(height: Spacing.lg), - ], - ), - ), + ) + : _buildEmptyState(context), ), ); } @@ -147,48 +149,49 @@ class _GoalsPageState extends State { Widget _buildDesktopLayout(BuildContext context, GoalsProvider provider) { final textTheme = Theme.of(context).textTheme; + final hasGoals = provider.hasActiveGoals || provider.hasCompletedGoals; return Scaffold( body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Stats summary row - if (provider.hasActiveGoals || provider.hasCompletedGoals) ...[ - _buildStatsRow(provider, isDesktop: true), - const SizedBox(height: Spacing.lg), - ], - // Active goals section - if (provider.hasActiveGoals) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: hasGoals + ? SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Active goals', style: textTheme.titleMedium), - FilledButton.icon( - onPressed: () => _showAddGoalSheet(context), - icon: const Icon(Icons.add), - label: const Text('New goal'), - ), + // Stats summary row + if (provider.hasActiveGoals || provider.hasCompletedGoals) ...[ + _buildStatsRow(provider, isDesktop: true), + const SizedBox(height: Spacing.lg), + ], + // Active goals section + if (provider.hasActiveGoals) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Active goals', style: textTheme.titleMedium), + FilledButton.icon( + onPressed: () => _showAddGoalSheet(context), + icon: const Icon(Icons.add), + label: const Text('New goal'), + ), + ], + ), + const SizedBox(height: Spacing.md), + _buildGoalGrid(context, provider.activeGoals), + ], + // Completed goals section + if (provider.hasCompletedGoals) ...[ + const SizedBox(height: Spacing.xl), + _buildCompletedHeader(context, provider.completedGoals.length), + const SizedBox(height: Spacing.md), + if (!_completedCollapsed) + Opacity(opacity: 0.6, child: _buildGoalGrid(context, provider.completedGoals)), + ], ], ), - const SizedBox(height: Spacing.md), - _buildGoalGrid(context, provider.activeGoals), - ], - // Empty state - if (!provider.hasActiveGoals) _buildEmptyState(context), - // Completed goals section - if (provider.hasCompletedGoals) ...[ - const SizedBox(height: Spacing.xl), - _buildCompletedHeader(context, provider.completedGoals.length), - const SizedBox(height: Spacing.md), - if (!_completedCollapsed) - Opacity(opacity: 0.6, child: _buildGoalGrid(context, provider.completedGoals)), - ], - ], - ), - ), + ) + : _buildEmptyState(context), ), ); } @@ -280,32 +283,15 @@ class _GoalsPageState extends State { // ============================================================================ // EMPTY STATE // ============================================================================ - Widget _buildEmptyState(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Container( - padding: const EdgeInsets.all(Spacing.xl), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.flag_outlined, size: 64, color: colorScheme.onSurfaceVariant), - const SizedBox(height: Spacing.lg), - Text('No goals yet', style: textTheme.headlineSmall, textAlign: TextAlign.center), - const SizedBox(height: Spacing.sm), - Text( - 'Create your first reading goal to track your progress', - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: Spacing.lg), - FilledButton.icon( - onPressed: () => _showAddGoalSheet(context), - icon: const Icon(Icons.add), - label: const Text('Create goal'), - ), - ], + return EmptyState( + icon: Icons.flag_outlined, + title: 'No goals yet', + subtitle: 'Create your first reading goal to track your progress', + action: FilledButton.icon( + onPressed: () => _showAddGoalSheet(context), + icon: const Icon(Icons.add), + label: const Text('Create goal'), ), ); } diff --git a/app/test/pages/goals_page_test.dart b/app/test/pages/goals_page_test.dart new file mode 100644 index 0000000..2c9a579 --- /dev/null +++ b/app/test/pages/goals_page_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/pages/goals_page.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + Future pumpEmptyGoalsPage(WidgetTester tester, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final dataStore = DataStore()..loadData(readingGoals: const []); + await tester.pumpWidget(createTestPage(page: const GoalsPage(), dataStore: dataStore, screenSize: size)); + await tester.pumpAndSettle(); + } + + for (final size in [const Size(400, 800), const Size(1200, 800)]) { + testWidgets('empty state is vertically centered at ${size.width.toInt()}px', (tester) async { + await pumpEmptyGoalsPage(tester, size); + + final titleCenter = tester.getCenter(find.text('No goals yet')); + expect(titleCenter.dy, closeTo(size.height / 2, 100)); + }); + } +} From a9ada0c33eb8c7b87da933c452b234970be0ff65 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 00:39:30 +0300 Subject: [PATCH 02/12] feat: update NoteDialog layout for improved content field behavior and add corresponding test --- app/lib/widgets/book_details/note_dialog.dart | 269 +++++++++--------- .../book_details/note_dialog_test.dart | 40 +++ 2 files changed, 173 insertions(+), 136 deletions(-) create mode 100644 app/test/widgets/book_details/note_dialog_test.dart diff --git a/app/lib/widgets/book_details/note_dialog.dart b/app/lib/widgets/book_details/note_dialog.dart index 002a783..92b42a6 100644 --- a/app/lib/widgets/book_details/note_dialog.dart +++ b/app/lib/widgets/book_details/note_dialog.dart @@ -114,157 +114,154 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> { return Padding( padding: EdgeInsets.only(bottom: bottomInset), - child: DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0), - child: Column( - children: [ - const BottomSheetHandle(), - const SizedBox(height: Spacing.md), - BottomSheetHeader( - title: isEditing ? 'Edit note' : 'New note', - onCancel: () => Navigator.of(context).pop(), - onSave: _save, - ), - ], - ), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.85), + child: Container( + key: const Key('note-bottom-sheet'), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0), + child: Column( + children: [ + const BottomSheetHandle(), + const SizedBox(height: Spacing.md), + BottomSheetHeader( + title: isEditing ? 'Edit note' : 'New note', + onCancel: () => Navigator.of(context).pop(), + onSave: _save, + ), + ], ), - const SizedBox(height: Spacing.md), - const Divider(height: 1), + ), + const SizedBox(height: Spacing.md), + const Divider(height: 1), - // Form - Expanded( - child: Form( - key: _formKey, - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(Spacing.md), - sliver: SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title field - TextFormField( - controller: _titleController, - focusNode: _titleFocusNode, - decoration: const InputDecoration( - labelText: 'Title', - hintText: 'Enter note title', - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.sentences, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a title'; - } - return null; - }, + // Form + Flexible( + fit: FlexFit.loose, + child: Form( + key: _formKey, + child: CustomScrollView( + key: const Key('note-form-scroll'), + scrollBehavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + shrinkWrap: true, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(Spacing.md), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title field + TextFormField( + controller: _titleController, + focusNode: _titleFocusNode, + decoration: const InputDecoration( + labelText: 'Title', + hintText: 'Enter note title', + border: OutlineInputBorder(), ), - ], - ), + textCapitalization: TextCapitalization.sentences, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a title'; + } + return null; + }, + ), + ], ), ), + ), - // Content field — fills remaining space - SliverFillRemaining( - hasScrollBody: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(Spacing.md, 0, Spacing.md, Spacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextFormField( - controller: _contentController, - decoration: const InputDecoration( - labelText: 'Content', - hintText: 'Write your note...', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - textCapitalization: TextCapitalization.sentences, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter some content'; - } - return null; - }, - ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(Spacing.md, 0, Spacing.md, Spacing.md), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + key: const Key('note-content-field'), + controller: _contentController, + decoration: const InputDecoration( + labelText: 'Content', + hintText: 'Write your note...', + border: OutlineInputBorder(), + alignLabelWithHint: true, ), - const SizedBox(height: Spacing.md), + textCapitalization: TextCapitalization.sentences, + minLines: 8, + maxLines: 12, + textAlignVertical: TextAlignVertical.top, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter some content'; + } + return null; + }, + ), + const SizedBox(height: Spacing.md), - // Tag input - Row( - children: [ - Expanded( - child: TextField( - controller: _tagController, - decoration: const InputDecoration( - labelText: 'Tags', - hintText: 'Add a tag...', - border: OutlineInputBorder(), - isDense: true, - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) => _addTag(), + // Tag input + Row( + children: [ + Expanded( + child: TextField( + controller: _tagController, + decoration: const InputDecoration( + labelText: 'Tags', + hintText: 'Add a tag...', + border: OutlineInputBorder(), + isDense: true, ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _addTag(), ), - const SizedBox(width: Spacing.sm), - IconButton.filled(onPressed: _addTag, icon: const Icon(Icons.add)), - ], - ), - - // Tags display - const SizedBox(height: Spacing.sm), - if (_tags.isNotEmpty) - Wrap( - spacing: Spacing.xs, - runSpacing: Spacing.xs, - children: _tags.map((tag) { - return Chip( - label: Text(tag), - deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () => _removeTag(tag), - visualDensity: VisualDensity.compact, - ); - }).toList(), - ) - else - Text( - 'Tags will appear here', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), - ], - ), + const SizedBox(width: Spacing.sm), + IconButton.filled(onPressed: _addTag, icon: const Icon(Icons.add)), + ], + ), + + // Tags display + const SizedBox(height: Spacing.sm), + if (_tags.isNotEmpty) + Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: _tags.map((tag) { + return Chip( + label: Text(tag), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => _removeTag(tag), + visualDensity: VisualDensity.compact, + ); + }).toList(), + ) + else + Text( + 'Tags will appear here', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], ), ), - ], - ), + ), + ], ), ), - ], - ), - ); - }, + ), + ], + ), + ), ), ); } diff --git a/app/test/widgets/book_details/note_dialog_test.dart b/app/test/widgets/book_details/note_dialog_test.dart new file mode 100644 index 0000000..1b5e099 --- /dev/null +++ b/app/test/widgets/book_details/note_dialog_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/widgets/book_details/note_dialog.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; + +void main() { + testWidgets('content field uses a bounded multiline height', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => NoteDialog.show(context, bookId: 'book-1'), + child: const Text('Add note'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Add note')); + await tester.pumpAndSettle(); + + final contentField = find.byKey(const Key('note-content-field')); + final editableText = tester.widget( + find.descendant(of: contentField, matching: find.byType(EditableText)), + ); + + expect(editableText.expands, isFalse); + expect(editableText.minLines, 8); + expect(editableText.maxLines, 12); + + await tester.drag(find.byType(BottomSheetHandle), const Offset(0, -300)); + await tester.pumpAndSettle(); + + final sheetBottom = tester.getBottomRight(find.byKey(const Key('note-bottom-sheet'))).dy; + final tagsBottom = tester.getBottomRight(find.text('Tags will appear here')).dy; + expect(sheetBottom - tagsBottom, lessThan(80)); + }); +} From f13fb530ddad5c14e682af9e300e2f1af7f7526e Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 00:42:33 +0300 Subject: [PATCH 03/12] feat: refactor AnnotationDialog layout for improved usability and add corresponding test --- .../book_details/annotation_dialog.dart | 251 +++++++++--------- .../book_details/annotation_dialog_test.dart | 30 +++ 2 files changed, 154 insertions(+), 127 deletions(-) create mode 100644 app/test/widgets/book_details/annotation_dialog_test.dart diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index de2a967..3f5d56a 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -82,142 +82,139 @@ class _AnnotationDialogState extends State { return Padding( padding: EdgeInsets.only(bottom: bottomInset), - child: DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0), - child: Column( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.85), + child: Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0), + child: Column( + children: [ + const BottomSheetHandle(), + const SizedBox(height: Spacing.md), + BottomSheetHeader( + title: _isEditing ? 'Edit annotation' : 'New annotation', + onCancel: () => Navigator.of(context).pop(), + onSave: _save, + ), + ], + ), + ), + const SizedBox(height: Spacing.md), + const Divider(height: 1), + + // Form + Flexible( + fit: FlexFit.loose, + child: Form( + key: _formKey, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(Spacing.md), children: [ - const BottomSheetHandle(), + // Highlighted text + TextFormField( + controller: _textController, + decoration: const InputDecoration( + labelText: 'Highlighted text', + hintText: 'Enter the passage you highlighted...', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + textCapitalization: TextCapitalization.sentences, + maxLines: 4, + autofocus: !_isEditing, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter the highlighted text'; + } + return null; + }, + ), const SizedBox(height: Spacing.md), - BottomSheetHeader( - title: _isEditing ? 'Edit annotation' : 'New annotation', - onCancel: () => Navigator.of(context).pop(), - onSave: _save, + + // Page number + TextFormField( + controller: _pageController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Page number', border: OutlineInputBorder()), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a page number'; + } + final page = int.tryParse(value); + if (page == null || page < 1) { + return 'Enter a valid page number'; + } + return null; + }, ), - ], - ), - ), - const SizedBox(height: Spacing.md), - const Divider(height: 1), - - // Form - Expanded( - child: Form( - key: _formKey, - child: ListView( - controller: scrollController, - padding: const EdgeInsets.all(Spacing.md), - children: [ - // Highlighted text - TextFormField( - controller: _textController, - decoration: const InputDecoration( - labelText: 'Highlighted text', - hintText: 'Enter the passage you highlighted...', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - textCapitalization: TextCapitalization.sentences, - maxLines: 4, - autofocus: !_isEditing, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter the highlighted text'; - } - return null; - }, - ), - const SizedBox(height: Spacing.md), - - // Page number - TextFormField( - controller: _pageController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration(labelText: 'Page number', border: OutlineInputBorder()), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a page number'; - } - final page = int.tryParse(value); - if (page == null || page < 1) { - return 'Enter a valid page number'; - } - return null; - }, - ), - const SizedBox(height: Spacing.md), - - // Chapter title (optional) - TextFormField( - controller: _chapterController, - decoration: const InputDecoration( - labelText: 'Chapter title (optional)', - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.sentences, + const SizedBox(height: Spacing.md), + + // Chapter title (optional) + TextFormField( + controller: _chapterController, + decoration: const InputDecoration( + labelText: 'Chapter title (optional)', + border: OutlineInputBorder(), ), - const SizedBox(height: Spacing.md), - - // Note (optional) - TextFormField( - controller: _noteController, - decoration: const InputDecoration( - labelText: 'Note (optional)', - hintText: 'Add your thoughts about this passage...', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - textCapitalization: TextCapitalization.sentences, - maxLines: 3, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: Spacing.md), + + // Note (optional) + TextFormField( + controller: _noteController, + decoration: const InputDecoration( + labelText: 'Note (optional)', + hintText: 'Add your thoughts about this passage...', + border: OutlineInputBorder(), + alignLabelWithHint: true, ), + textCapitalization: TextCapitalization.sentences, + maxLines: 3, + ), - const SizedBox(height: Spacing.md), - - // Highlight color - Text('Highlight color', style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: Spacing.sm), - Wrap( - spacing: Spacing.sm, - children: HighlightColor.values.map((color) { - final isSelected = color == _selectedColor; - return GestureDetector( - onTap: () => setState(() => _selectedColor = color), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: color.color, - shape: BoxShape.circle, - border: isSelected ? Border.all(color: color.accentColor, width: 2) : null, - ), - child: isSelected - ? Icon(Icons.check, color: color.accentColor, size: IconSizes.small) - : null, + const SizedBox(height: Spacing.md), + + // Highlight color + Text('Highlight color', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + children: HighlightColor.values.map((color) { + final isSelected = color == _selectedColor; + return GestureDetector( + onTap: () => setState(() => _selectedColor = color), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.color, + shape: BoxShape.circle, + border: isSelected ? Border.all(color: color.accentColor, width: 2) : null, ), - ); - }).toList(), - ), - ], - ), + child: isSelected + ? Icon(Icons.check, color: color.accentColor, size: IconSizes.small) + : null, + ), + ); + }).toList(), + ), + ], ), ), - ], - ), - ); - }, + ), + ], + ), + ), ), ); } diff --git a/app/test/widgets/book_details/annotation_dialog_test.dart b/app/test/widgets/book_details/annotation_dialog_test.dart new file mode 100644 index 0000000..bdfa869 --- /dev/null +++ b/app/test/widgets/book_details/annotation_dialog_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; + +void main() { + testWidgets('annotation sheet sizes to its form content', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => AnnotationDialog.show(context, bookId: 'book-1'), + child: const Text('Add annotation'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Add annotation')); + await tester.pumpAndSettle(); + + expect(find.byType(DraggableScrollableSheet), findsNothing); + expect(find.text('New annotation'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(find.text('Highlight color'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} From 7e5c46848dc83980cc3548f9222f691be65be21f Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 00:50:53 +0300 Subject: [PATCH 04/12] feat: add empty state handling for shelves and topics, including corresponding tests --- .../widgets/shelves/move_to_shelf_sheet.dart | 59 +++++++++++-------- .../widgets/topics/manage_topics_sheet.dart | 16 ++--- .../selection_sheets_empty_state_test.dart | 42 +++++++++++++ 3 files changed, 81 insertions(+), 36 deletions(-) create mode 100644 app/test/widgets/shared/selection_sheets_empty_state_test.dart diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index 6f946e9..42d6ed7 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -7,6 +7,7 @@ import 'package:papyrus/utils/text_utils.dart'; import 'package:papyrus/widgets/input/search_field.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; import 'package:provider/provider.dart'; @@ -152,32 +153,35 @@ class _MoveToShelfSheetState extends State { // Shelf list Expanded( - child: Builder( - builder: (context) { - final filteredShelves = _searchQuery.isEmpty - ? shelves - : shelves - .where( - (searchString) => searchString.name.toLowerCase().contains(_searchQuery.toLowerCase()), - ) - .toList(); + child: shelves.isEmpty + ? _buildEmptyState() + : Builder( + builder: (context) { + final filteredShelves = _searchQuery.isEmpty + ? shelves + : shelves + .where( + (searchString) => + searchString.name.toLowerCase().contains(_searchQuery.toLowerCase()), + ) + .toList(); - if (filteredShelves.isEmpty && _searchQuery.isNotEmpty) { - return Center( - child: Text( - 'No shelves found', - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ); - } + if (filteredShelves.isEmpty && _searchQuery.isNotEmpty) { + return Center( + child: Text( + 'No shelves found', + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ); + } - return ListView.builder( - controller: scrollController, - itemCount: filteredShelves.length, - itemBuilder: (context, index) => _buildShelfTile(context, filteredShelves[index]), - ); - }, - ), + return ListView.builder( + controller: scrollController, + itemCount: filteredShelves.length, + itemBuilder: (context, index) => _buildShelfTile(context, filteredShelves[index]), + ); + }, + ), ), // Action buttons @@ -209,6 +213,13 @@ class _MoveToShelfSheetState extends State { return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } + Widget _buildEmptyState() { + return const SizedBox( + width: double.infinity, + child: EmptyState(icon: Icons.shelves, title: 'No shelves yet', subtitle: 'Tap + to create a shelf'), + ); + } + Widget _buildShelfTile(BuildContext context, Shelf shelf) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index 69e592d..7f6e442 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -7,6 +7,7 @@ import 'package:papyrus/utils/text_utils.dart'; import 'package:papyrus/widgets/input/search_field.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/topics/add_topic_sheet.dart'; import 'package:provider/provider.dart'; @@ -206,18 +207,9 @@ class _ManageTopicsSheetState extends State { } Widget _buildEmptyState(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.label_outline, size: IconSizes.display, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), - const SizedBox(height: Spacing.md), - Text('No topics yet', style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - Text('Tap + to create a topic', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ], + return const SizedBox( + width: double.infinity, + child: EmptyState(icon: Icons.label_outline, title: 'No topics yet', subtitle: 'Tap + to create a topic'), ); } diff --git a/app/test/widgets/shared/selection_sheets_empty_state_test.dart b/app/test/widgets/shared/selection_sheets_empty_state_test.dart new file mode 100644 index 0000000..fd0e74d --- /dev/null +++ b/app/test/widgets/shared/selection_sheets_empty_state_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; +import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + final dataStore = DataStore()..loadData(shelves: const [], tags: const []); + + Future pumpSheet(WidgetTester tester, Widget sheet) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 1200); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + createTestPage( + page: Scaffold(body: sheet), + dataStore: dataStore, + screenSize: const Size(800, 1200), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('topics empty state is centered across the sheet', (tester) async { + await pumpSheet(tester, const ManageTopicsSheet(bulkBookIds: ['book-1'])); + + final title = find.text('No topics yet'); + expect(title, findsOneWidget); + expect(tester.getCenter(title).dx, closeTo(400, 20)); + }); + + testWidgets('shelves empty state is present and centered across the sheet', (tester) async { + await pumpSheet(tester, const MoveToShelfSheet(bulkBookIds: ['book-1'])); + + final title = find.text('No shelves yet'); + expect(title, findsOneWidget); + expect(tester.getCenter(title).dx, closeTo(400, 20)); + }); +} From 44328d55444cc0cbc70858450a7d4267198d5fa7 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 01:21:02 +0300 Subject: [PATCH 05/12] docs: define responsive book edit panes --- ...-07-13-book-edit-responsive-pane-design.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md diff --git a/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md b/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md new file mode 100644 index 0000000..c73bea2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md @@ -0,0 +1,43 @@ +# Book Edit Responsive Pane Design + +## Goal + +Use desktop space efficiently on the book edit page without allowing the cover pane or form fields to become cramped. Preserve the existing cover editing workflow and mobile layout. + +## Layout + +The desktop edit body uses the width allocated by its parent, excluding the application sidebar. + +- When the available content width can accommodate a 280 px supporting pane, the standard pane gap, page padding, and at least 420 px for the form, show the cover pane and form side by side. +- The cover pane has a fixed width of 280 px. The cover preview remains constrained and does not grow with the page. +- The form pane is flexible and consumes the remaining width, up to the page's existing maximum width. +- When less space is available, stack the cover pane above the form while retaining desktop-sized cover constraints. Do not switch to the mobile full-width cover merely because the panes stack. + +The breakpoint is derived from these minimum pane dimensions and evaluated with `LayoutBuilder`, rather than from the full browser width. + +## Form Rows + +Rows containing two optional fields respond to the width of the form pane itself: + +- Display fields side by side when both receive a usable input width. +- Stack fields vertically when the form pane is too narrow. + +This behavior prevents field truncation while allowing the page-level panes to remain side by side at intermediate desktop widths. + +## Unchanged Behavior + +- Cover upload, URL entry, removal, preview, and persistence are unchanged. +- Metadata lookup behavior is unchanged. +- Form validation, saving, routing, and unsaved-change handling are unchanged. +- The existing mobile page layout is unchanged. +- The desktop header and Save action remain unchanged. + +## Verification + +Widget tests cover three states: + +1. Wide desktop: cover and form panes are side by side. +2. Intermediate desktop: panes remain side by side while paired form fields stack as necessary, with no render overflow. +3. Constrained desktop: cover pane stacks above the form and the cover preview stays compact. + +Static analysis must report no issues in the changed files. From 4df51db2f60f743ec68022830e03e417d045bd33 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 01:24:38 +0300 Subject: [PATCH 06/12] fix: improve responsive book edit layout --- app/lib/pages/book_edit_page.dart | 75 +++++--- .../book_form/responsive_form_row.dart | 36 ++-- .../pages/book_edit_page_layout_test.dart | 43 ++++- .../2026-07-13-book-edit-responsive-pane.md | 172 ++++++++++++++++++ 4 files changed, 279 insertions(+), 47 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 1354cf1..5ac9783 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -33,6 +33,11 @@ class BookEditPage extends StatefulWidget { } class _BookEditPageState extends State { + static const double _desktopCoverPaneWidth = 280; + static const double _minimumDesktopFormPaneWidth = 420; + static const double _desktopPaneBreakpoint = + _desktopCoverPaneWidth + Spacing.xl + _minimumDesktopFormPaneWidth + (Spacing.lg * 2); + late BookEditProvider _provider; final _formKey = GlobalKey(); @@ -271,39 +276,55 @@ class _BookEditPageState extends State { alignment: Alignment.topLeft, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1120), - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.lg), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left pane - Cover + Metadata - SizedBox( - width: 280, - child: Column( - children: [ - _buildSectionCard( - title: 'Cover', - children: [_buildCoverSection(context, provider, isDesktop: true)], + child: LayoutBuilder( + builder: (context, constraints) { + final showSideBySide = constraints.maxWidth >= _desktopPaneBreakpoint; + + return SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.lg), + child: showSideBySide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDesktopCoverPane(context, provider), + const SizedBox(width: Spacing.xl), + Expanded(child: _buildDesktopFormPane(context, provider)), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDesktopCoverPane(context, provider), + const SizedBox(height: Spacing.xl), + _buildDesktopFormPane(context, provider), + ], ), - _buildSectionCard(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), - ], - ), - ), - const SizedBox(width: Spacing.xl), - // Right pane - Form fields - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _buildFormSections(context, provider, skipMetadata: true), - ), - ), - ], - ), + ); + }, ), ), ); } + Widget _buildDesktopCoverPane(BuildContext context, BookEditProvider provider) { + return SizedBox( + width: _desktopCoverPaneWidth, + child: Column( + children: [ + _buildSectionCard(title: 'Cover', children: [_buildCoverSection(context, provider, isDesktop: true)]), + _buildSectionCard(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), + ], + ), + ); + } + + Widget _buildDesktopFormPane(BuildContext context, BookEditProvider provider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildFormSections(context, provider, skipMetadata: true), + ); + } + List _buildFormSections(BuildContext context, BookEditProvider provider, {bool skipMetadata = false}) { return [ _buildBasicInfoSection(context, provider), diff --git a/app/lib/widgets/book_form/responsive_form_row.dart b/app/lib/widgets/book_form/responsive_form_row.dart index d49be95..240b65c 100644 --- a/app/lib/widgets/book_form/responsive_form_row.dart +++ b/app/lib/widgets/book_form/responsive_form_row.dart @@ -11,22 +11,28 @@ class ResponsiveFormRow extends StatelessWidget { @override Widget build(BuildContext context) { - if (!isDesktop || children.length == 1) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children.expand((w) sync* { - yield w; - yield const SizedBox(height: Spacing.md); - }).toList()..removeLast(), - ); - } + return LayoutBuilder( + builder: (context, constraints) { + final showAsRow = isDesktop && children.length > 1 && constraints.maxWidth >= Breakpoints.tablet; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: children.expand((w) sync* { - yield Expanded(child: w); - yield const SizedBox(width: Spacing.md); - }).toList()..removeLast(), + if (!showAsRow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children.expand((widget) sync* { + yield widget; + yield const SizedBox(height: Spacing.md); + }).toList()..removeLast(), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children.expand((widget) sync* { + yield Expanded(child: widget); + yield const SizedBox(width: Spacing.md); + }).toList()..removeLast(), + ); + }, ); } } diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart index 3e9f44a..3d8b45c 100644 --- a/app/test/pages/book_edit_page_layout_test.dart +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -9,7 +9,7 @@ import '../helpers/test_helpers.dart'; void main() { group('BookEditPage layout', () { - Future pumpPage(WidgetTester tester, {required Size size}) async { + Future pumpPage(WidgetTester tester, {required Size size, double? contentWidth}) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; addTearDown(tester.view.reset); @@ -20,12 +20,20 @@ void main() { author: 'Mary Shelley', ); + Widget page = Theme( + data: AppTheme.dark, + child: const BookEditPage(id: 'book-1'), + ); + if (contentWidth != null) { + page = Align( + alignment: Alignment.topLeft, + child: SizedBox(width: contentWidth, height: size.height, child: page), + ); + } + await tester.pumpWidget( createTestPage( - page: Theme( - data: AppTheme.dark, - child: const BookEditPage(id: 'book-1'), - ), + page: page, dataStore: createTestDataStore(books: [book]), screenSize: size, ), @@ -63,6 +71,31 @@ void main() { expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); }); + testWidgets('intermediate desktop keeps panes side by side while paired fields stack', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 800); + + final coverHeading = tester.getTopLeft(find.text('Cover')); + final formHeading = tester.getTopLeft(find.text('Basic information')); + final publisher = tester.getTopLeft(find.text('Publisher')); + final language = tester.getTopLeft(find.text('Language')); + + expect(formHeading.dx, greaterThan(coverHeading.dx)); + expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); + expect(language.dy, greaterThan(publisher.dy)); + expect(tester.takeException(), isNull); + }); + + testWidgets('constrained desktop stacks panes and keeps the cover compact', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 760); + + final coverHeading = tester.getTopLeft(find.text('Cover')); + final formHeading = tester.getTopLeft(find.text('Basic information')); + + expect(formHeading.dy, greaterThan(coverHeading.dy)); + expect(tester.getSize(find.byType(AspectRatio).first).width, 240); + expect(tester.takeException(), isNull); + }); + testWidgets('narrow layouts stack the cover above the form without overflow', (tester) async { await pumpPage(tester, size: const Size(600, 1600)); diff --git a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md new file mode 100644 index 0000000..b7e47c7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md @@ -0,0 +1,172 @@ +# Book Edit Responsive Pane 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 cover and edit form side by side at useful intermediate desktop widths while preventing narrow form fields and oversized stacked covers. + +**Architecture:** `BookEditPage` will use its parent constraints and minimum pane dimensions to choose between a supporting-pane row and a stacked desktop layout. `ResponsiveFormRow` will independently use its own constraints to stack paired inputs when the flexible form pane is narrow. + +**Tech Stack:** Flutter, Dart, Material widgets, `LayoutBuilder`, Flutter widget tests + +--- + +### Task 1: Capture intermediate and constrained desktop behavior + +**Files:** +- Modify: `app/test/pages/book_edit_page_layout_test.dart` + +- [ ] **Step 1: Replace the constrained-desktop test and add the intermediate case** + +Use an 800 px content allocation to assert that the cover and form remain side by side while Publisher and Language stack inside the narrower form pane. Use a 760 px allocation to assert that the page panes stack and the cover preview remains 240 px wide. + +```dart +testWidgets('intermediate desktop keeps panes side by side while paired fields stack', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 800); + + final coverHeading = tester.getTopLeft(find.text('Cover')); + final formHeading = tester.getTopLeft(find.text('Basic information')); + final publisher = tester.getTopLeft(find.text('Publisher')); + final language = tester.getTopLeft(find.text('Language')); + + expect(formHeading.dx, greaterThan(coverHeading.dx)); + expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); + expect(language.dy, greaterThan(publisher.dy)); + expect(tester.takeException(), isNull); +}); + +testWidgets('constrained desktop stacks panes and keeps the cover compact', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 760); + + final coverHeading = tester.getTopLeft(find.text('Cover')); + final formHeading = tester.getTopLeft(find.text('Basic information')); + + expect(formHeading.dy, greaterThan(coverHeading.dy)); + expect(tester.getSize(find.byType(AspectRatio).first).width, 240); + expect(tester.takeException(), isNull); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify the intermediate case fails** + +Run: + +```bash +cd app +flutter test test/pages/book_edit_page_layout_test.dart +``` + +Expected: the 800 px case fails because the current 840 px page breakpoint stacks the cover above the form. + +### Task 2: Make page panes respond to usable minimum widths + +**Files:** +- Modify: `app/lib/pages/book_edit_page.dart` + +- [ ] **Step 1: Derive the pane breakpoint from layout dimensions** + +Add constants to `_BookEditPageState` and use them in `_buildDesktopLayout` and `_buildDesktopCoverPane`: + +```dart +static const double _desktopCoverPaneWidth = 280; +static const double _minimumDesktopFormPaneWidth = 420; +static const double _desktopPaneBreakpoint = + _desktopCoverPaneWidth + Spacing.xl + _minimumDesktopFormPaneWidth + (Spacing.lg * 2); +``` + +Replace the generic desktop breakpoint check: + +```dart +final showSideBySide = constraints.maxWidth >= _desktopPaneBreakpoint; +``` + +Set the cover pane width from the shared dimension: + +```dart +return SizedBox( + width: _desktopCoverPaneWidth, + child: Column(...), +); +``` + +- [ ] **Step 2: Run the layout tests** + +Run: + +```bash +cd app +flutter test test/pages/book_edit_page_layout_test.dart +``` + +Expected: the page-level position assertions pass; the intermediate paired-field assertion still fails because `ResponsiveFormRow` uses the browser-level desktop flag. + +### Task 3: Make paired form fields respond to their allocated width + +**Files:** +- Modify: `app/lib/widgets/book_form/responsive_form_row.dart` +- Test: `app/test/pages/book_edit_page_layout_test.dart` + +- [ ] **Step 1: Use local constraints for the horizontal row decision** + +Keep `isDesktop` as the mobile/desktop policy input, but use `LayoutBuilder` to require at least the existing tablet breakpoint before placing multiple fields side by side: + +```dart +@override +Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final showAsRow = + isDesktop && children.length > 1 && constraints.maxWidth >= Breakpoints.tablet; + + if (!showAsRow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children.expand((widget) sync* { + yield widget; + yield const SizedBox(height: Spacing.md); + }).toList() + ..removeLast(), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children.expand((widget) sync* { + yield Expanded(child: widget); + yield const SizedBox(width: Spacing.md); + }).toList() + ..removeLast(), + ); + }, + ); +} +``` + +- [ ] **Step 2: Format and run the complete layout test file** + +Run: + +```bash +dart format app/lib/pages/book_edit_page.dart app/lib/widgets/book_form/responsive_form_row.dart app/test/pages/book_edit_page_layout_test.dart +cd app +flutter test test/pages/book_edit_page_layout_test.dart +``` + +Expected: all book edit layout tests pass with no render-overflow exceptions. + +- [ ] **Step 3: Run static analysis** + +Run: + +```bash +cd app +flutter analyze lib/pages/book_edit_page.dart lib/widgets/book_form/responsive_form_row.dart test/pages/book_edit_page_layout_test.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 4: Commit the implementation** + +```bash +git add app/lib/pages/book_edit_page.dart app/lib/widgets/book_form/responsive_form_row.dart app/test/pages/book_edit_page_layout_test.dart docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md +git commit -m "fix: improve responsive book edit layout" +``` From f264f7db8a9ccff4becd1a496ec875633776619c Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 02:01:33 +0300 Subject: [PATCH 07/12] docs: refine metadata lookup layout --- .../2026-07-13-book-edit-responsive-pane-design.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md b/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md index c73bea2..0d47925 100644 --- a/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md +++ b/docs/superpowers/specs/2026-07-13-book-edit-responsive-pane-design.md @@ -15,6 +15,17 @@ The desktop edit body uses the width allocated by its parent, excluding the appl The breakpoint is derived from these minimum pane dimensions and evaluated with `LayoutBuilder`, rather than from the full browser width. +## Metadata Lookup + +Metadata lookup belongs to the form rather than the cover pane because it updates multiple book fields, not only the cover. + +- Place `Fetch metadata` as the first form section on desktop. +- Give the search field the full section width so it is the primary control. +- Place a compact, always-visible source selector below the search field, preceded by the label `Source`. +- Keep the existing Open Library and Google Books selection behavior. +- Do not introduce a dropdown or combine the source selector into the search input. +- Keep error messages and search results below these controls. + ## Form Rows Rows containing two optional fields respond to the width of the form pane itself: @@ -38,6 +49,7 @@ Widget tests cover three states: 1. Wide desktop: cover and form panes are side by side. 2. Intermediate desktop: panes remain side by side while paired form fields stack as necessary, with no render overflow. -3. Constrained desktop: cover pane stacks above the form and the cover preview stays compact. +3. Constrained desktop: the centered cover pane stacks above the form and the cover preview stays compact. +4. Desktop metadata lookup: the full-width search field precedes the compact source selector within the form pane. Static analysis must report no issues in the changed files. From dc106f85c7a269581d9dcc1d2837bad07af2eaf8 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 02:10:45 +0300 Subject: [PATCH 08/12] feat: enhance layout for book edit page with improved metadata search hierarchy --- app/lib/pages/book_edit_page.dart | 91 ++++++++++--------- .../pages/book_edit_page_layout_test.dart | 53 +++++++++-- .../2026-07-13-book-edit-responsive-pane.md | 67 ++++++++++++++ 3 files changed, 158 insertions(+), 53 deletions(-) diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 5ac9783..b46d7b7 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -292,9 +292,9 @@ class _BookEditPageState extends State { ], ) : Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildDesktopCoverPane(context, provider), + Center(child: _buildDesktopCoverPane(context, provider)), const SizedBox(height: Spacing.xl), _buildDesktopFormPane(context, provider), ], @@ -307,21 +307,20 @@ class _BookEditPageState extends State { } Widget _buildDesktopCoverPane(BuildContext context, BookEditProvider provider) { - return SizedBox( - width: _desktopCoverPaneWidth, - child: Column( - children: [ - _buildSectionCard(title: 'Cover', children: [_buildCoverSection(context, provider, isDesktop: true)]), - _buildSectionCard(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), - ], - ), - ); + return SizedBox(width: _desktopCoverPaneWidth, child: _buildDesktopCoverCard(context, provider)); + } + + Widget _buildDesktopCoverCard(BuildContext context, BookEditProvider provider) { + return _buildSectionCard(title: 'Cover', children: [_buildCoverSection(context, provider, isDesktop: true)]); } Widget _buildDesktopFormPane(BuildContext context, BookEditProvider provider) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: _buildFormSections(context, provider, skipMetadata: true), + children: [ + _buildSectionCard(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), + ..._buildFormSections(context, provider, skipMetadata: true), + ], ); } @@ -579,44 +578,50 @@ class _BookEditPageState extends State { Widget _buildMetadataSection(BuildContext context, BookEditProvider provider) { final colorScheme = Theme.of(context).colorScheme; + final sourceSelector = SegmentedButton( + segments: const [ + ButtonSegment(value: MetadataSource.openLibrary, label: Text('Open Library')), + ButtonSegment(value: MetadataSource.googleBooks, label: Text('Google Books')), + ], + selected: {provider.selectedSource}, + showSelectedIcon: false, + onSelectionChanged: (selection) { + provider.setMetadataSource(selection.first); + }, + style: const ButtonStyle(visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ); + final searchField = TextFormField( + controller: _metadataSearchController, + decoration: InputDecoration( + labelText: 'Search', + hintText: 'Title, author, or ISBN', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: provider.isFetching + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.arrow_forward), + onPressed: provider.isFetching ? null : () => _searchMetadata(provider), + ), + ), + onFieldSubmitted: (_) => _searchMetadata(provider), + ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Source selector - SegmentedButton( - segments: const [ - ButtonSegment(value: MetadataSource.openLibrary, label: Text('Open Library')), - ButtonSegment(value: MetadataSource.googleBooks, label: Text('Google Books')), + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text('Source', style: Theme.of(context).textTheme.bodySmall), + sourceSelector, ], - selected: {provider.selectedSource}, - onSelectionChanged: (selection) { - provider.setMetadataSource(selection.first); - }, - style: const ButtonStyle( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), ), - const SizedBox(height: Spacing.md), - // Search field - TextFormField( - controller: _metadataSearchController, - decoration: InputDecoration( - labelText: 'Search', - hintText: 'Title, author, or ISBN', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - prefixIcon: const Icon(Icons.search), - suffixIcon: IconButton( - icon: provider.isFetching - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.arrow_forward), - onPressed: provider.isFetching ? null : () => _searchMetadata(provider), - ), - ), - onFieldSubmitted: (_) => _searchMetadata(provider), - ), + const SizedBox(height: Spacing.md), + searchField, // Error message if (provider.fetchState == MetadataFetchState.error) ...[ diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart index 3d8b45c..cb4ba06 100644 --- a/app/test/pages/book_edit_page_layout_test.dart +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -61,41 +61,74 @@ void main() { expect(find.ancestor(of: saveButton, matching: find.byType(SingleChildScrollView)), findsNothing); }); - testWidgets('desktop keeps the cover and form fields side by side', (tester) async { + testWidgets('desktop keeps the cover beside metadata and form sections', (tester) async { await pumpPage(tester, size: const Size(1400, 1000)); final coverHeading = tester.getTopLeft(find.text('Cover')); + final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final formHeading = tester.getTopLeft(find.text('Basic information')); - expect(formHeading.dx, greaterThan(coverHeading.dx)); - expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); + expect(metadataHeading.dx, greaterThan(coverHeading.dx)); + expect((metadataHeading.dy - coverHeading.dy).abs(), lessThan(4)); + expect(formHeading.dx, closeTo(metadataHeading.dx, 1)); + expect(formHeading.dy, greaterThan(metadataHeading.dy)); }); testWidgets('intermediate desktop keeps panes side by side while paired fields stack', (tester) async { await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 800); final coverHeading = tester.getTopLeft(find.text('Cover')); - final formHeading = tester.getTopLeft(find.text('Basic information')); + final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final publisher = tester.getTopLeft(find.text('Publisher')); final language = tester.getTopLeft(find.text('Language')); - expect(formHeading.dx, greaterThan(coverHeading.dx)); - expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); + expect(metadataHeading.dx, greaterThan(coverHeading.dx)); + expect((metadataHeading.dy - coverHeading.dy).abs(), lessThan(4)); expect(language.dy, greaterThan(publisher.dy)); expect(tester.takeException(), isNull); }); - testWidgets('constrained desktop stacks panes and keeps the cover compact', (tester) async { - await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 760); + testWidgets('medium desktop centers the cover above full-width metadata and form sections', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); final coverHeading = tester.getTopLeft(find.text('Cover')); + final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final formHeading = tester.getTopLeft(find.text('Basic information')); - - expect(formHeading.dy, greaterThan(coverHeading.dy)); + final coverCard = find.ancestor(of: find.text('Cover'), matching: find.byType(Card)).first; + final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; + final formCard = find.ancestor(of: find.text('Basic information'), matching: find.byType(Card)).first; + + expect(metadataHeading.dy, greaterThan(coverHeading.dy)); + expect(formHeading.dy, greaterThan(metadataHeading.dy)); + expect(tester.getCenter(coverCard).dx, closeTo(320, 1)); + expect(tester.getCenter(metadataCard).dx, closeTo(320, 1)); + expect(tester.getSize(metadataCard).width, closeTo(tester.getSize(formCard).width, 1)); expect(tester.getSize(find.byType(AspectRatio).first).width, 240); expect(tester.takeException(), isNull); }); + testWidgets('metadata search precedes a compact visible source selector', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); + + final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; + final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; + final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); + final sourceLabel = find.text('Source'); + + expect(sourceLabel, findsOneWidget); + expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); + expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getBottomLeft(searchField).dy)); + expect((tester.getCenter(selector).dy - tester.getCenter(sourceLabel).dy).abs(), lessThan(1)); + }); + + testWidgets('metadata source selector does not reserve width for a selected icon', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); + + final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); + + expect(find.descendant(of: selector, matching: find.byIcon(Icons.check)), findsNothing); + }); + testWidgets('narrow layouts stack the cover above the form without overflow', (tester) async { await pumpPage(tester, size: const Size(600, 1600)); diff --git a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md index b7e47c7..9dba236 100644 --- a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md +++ b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md @@ -170,3 +170,70 @@ Expected: `No issues found!` git add app/lib/pages/book_edit_page.dart app/lib/widgets/book_form/responsive_form_row.dart app/test/pages/book_edit_page_layout_test.dart docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md git commit -m "fix: improve responsive book edit layout" ``` + +### Task 4: Give metadata search a clear visual hierarchy + +**Files:** +- Modify: `app/lib/pages/book_edit_page.dart` +- Test: `app/test/pages/book_edit_page_layout_test.dart` + +- [ ] **Step 1: Write the failing metadata control-order test** + +At a 640 px desktop content allocation, assert that the search field is full width, the compact source selector is below it, and the `Source` label precedes the selector. + +```dart +testWidgets('metadata search precedes a compact visible source selector', (tester) async { + await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); + + final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; + final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; + final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); + final sourceLabel = find.text('Source'); + + expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); + expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getBottomLeft(searchField).dy)); + expect((tester.getCenter(selector).dy - tester.getCenter(sourceLabel).dy).abs(), lessThan(1)); +}); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +cd app +flutter test test/pages/book_edit_page_layout_test.dart --plain-name "metadata search precedes a compact visible source selector" +``` + +Expected: FAIL because the current source selector is beside the search field and there is no `Source` label. + +- [ ] **Step 3: Replace the inline metadata row with search-first controls** + +Keep metadata inside the desktop form pane. In `_buildMetadataSection`, render the search field first, followed by a compact source row: + +```dart +searchField, +const SizedBox(height: Spacing.md), +Row( + children: [ + Text('Source', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(width: Spacing.md), + Flexible(child: sourceSelector), + ], +), +``` + +Remove `inlineControls`, `_metadataControlsRowBreakpoint`, and their `LayoutBuilder`; both desktop and mobile metadata sections use this same search-first hierarchy. + +- [ ] **Step 4: Format and verify** + +Run: + +```bash +dart format app/lib/pages/book_edit_page.dart app/test/pages/book_edit_page_layout_test.dart +cd app +flutter test test/pages/book_edit_page_layout_test.dart +flutter analyze lib/pages/book_edit_page.dart lib/widgets/book_form/responsive_form_row.dart test/pages/book_edit_page_layout_test.dart +``` + +Expected: all layout tests pass and analysis reports `No issues found!`. From 0bcf3670f5f95c6f7f25edd7656677e83c9e1417 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 02:12:19 +0300 Subject: [PATCH 09/12] refactor: rename 'Storage & sync' to 'Storage' for clarity in profile page and preferences provider --- app/lib/pages/profile_page.dart | 14 +++++++------- app/lib/providers/preferences_provider.dart | 4 ++-- app/test/pages/profile_storage_sync_test.dart | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index a8ca4f8..dcaef7c 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -210,7 +210,7 @@ class _ProfilePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Storage & sync'), + const SettingsSectionHeader(title: 'Storage'), const SettingsRow(label: 'Library', value: 'Stored on this device'), Padding( padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), @@ -234,7 +234,7 @@ class _ProfilePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Storage & sync'), + const SettingsSectionHeader(title: 'Storage'), SettingsRow( label: 'Data sync', value: controller.dataSyncLabel, @@ -391,13 +391,13 @@ class _ProfilePageState extends State { _buildNavItem( context, icon: Icons.cloud_outlined, - label: 'Storage & sync', + label: 'Storage', section: _ProfileSection.storageSync, ), _buildNavItem( context, icon: Icons.shield_outlined, - label: 'Privacy & data', + label: 'Privacy', section: _ProfileSection.privacyData, ), _buildNavItem( @@ -533,9 +533,9 @@ class _ProfilePageState extends State { case _ProfileSection.notifications: return 'Notifications'; case _ProfileSection.storageSync: - return 'Storage & sync'; + return 'Storage'; case _ProfileSection.privacyData: - return 'Privacy & data'; + return 'Privacy'; case _ProfileSection.accessibility: return 'Accessibility'; case _ProfileSection.about: @@ -923,7 +923,7 @@ class _ProfilePageState extends State { ); } - // -- Storage & sync --------------------------------------------------------- + // -- Storage --------------------------------------------------------- Widget _buildStorageSyncContent(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index c273b24..15c3abb 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -37,7 +37,7 @@ class PreferencesProvider extends ChangeNotifier { static const _keyStreakAlerts = 'streak_alerts'; static const _keySyncStatusNotifications = 'sync_status_notifications'; - // Storage & sync + // Storage static const _keyStorageBackend = 'storage_backend'; static const _keySyncEnabled = 'sync_enabled'; static const _keyServerUrl = 'server_url'; @@ -198,7 +198,7 @@ class PreferencesProvider extends ChangeNotifier { notifyListeners(); } - // -- Storage & sync ------------------------------------------------------- + // -- Storage ------------------------------------------------------- String get storageBackend => _prefs.getString(_keyStorageBackend) ?? 'Local'; diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 880538d..6deabba 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -157,7 +157,7 @@ void main() { await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.scrollUntilVisible(find.text('Storage'), 400); await tester.pumpAndSettle(); expect(find.text('Stored on this device'), findsOneWidget); @@ -191,7 +191,7 @@ void main() { await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), ); await tester.pump(); - await tester.tap(find.text('Storage & sync').first); + await tester.tap(find.text('Storage').first); await tester.pump(); expect(find.text('Library storage'), findsOneWidget); @@ -232,7 +232,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Storage & sync').first); + await tester.tap(find.text('Storage').first); await tester.pumpAndSettle(); expect(find.text('Data sync'), findsOneWidget); @@ -285,7 +285,7 @@ void main() { await buildPage(authProvider: auth, powerSyncService: service, syncSettingsProvider: syncSettings), ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.scrollUntilVisible(find.text('Storage'), 400); await tester.pumpAndSettle(); await tester.tap(find.text('Manage servers')); await tester.pumpAndSettle(); @@ -307,7 +307,7 @@ void main() { await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Storage & sync').first); + await tester.tap(find.text('Storage').first); await tester.pumpAndSettle(); expect(find.text('Error'), findsWidgets); From 6356ed74a4bf562fee5b9ba9b7dc8a76e1c485f4 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 02:16:59 +0300 Subject: [PATCH 10/12] fix: restore 'About' section in mobile profile layout for improved navigation --- app/lib/pages/profile_page.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index dcaef7c..9875984 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -74,8 +74,8 @@ class _ProfilePageState extends State { _buildMobileStorageSyncSection(context), _buildMobilePrivacyDataSection(context), _buildMobileAccessibilitySection(context), - _buildMobileAboutSection(context), if (kDebugMode) _buildMobileDeveloperSection(context), + _buildMobileAboutSection(context), const Divider(height: 1), _buildMenuItem( context, @@ -406,7 +406,6 @@ class _ProfilePageState extends State { label: 'Accessibility', section: _ProfileSection.accessibility, ), - _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: _ProfileSection.about), if (kDebugMode) _buildNavItem( context, @@ -414,8 +413,10 @@ class _ProfilePageState extends State { label: 'Developer options', section: _ProfileSection.developerOptions, ), - const SizedBox(height: Spacing.md), + _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: _ProfileSection.about), + const SizedBox(height: Spacing.sm), Divider(height: 1, color: colorScheme.outlineVariant), + const SizedBox(height: Spacing.sm), _buildNavItem( context, icon: Icons.logout, @@ -538,10 +539,10 @@ class _ProfilePageState extends State { return 'Privacy'; case _ProfileSection.accessibility: return 'Accessibility'; - case _ProfileSection.about: - return 'About'; case _ProfileSection.developerOptions: return 'Developer options'; + case _ProfileSection.about: + return 'About'; } } From 6d3db4a513ad33033262e4f40c82d532ea5c4a06 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 02:23:46 +0300 Subject: [PATCH 11/12] docs: define add book bottom sheets --- ...026-07-13-add-book-bottom-sheets-design.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md diff --git a/docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md b/docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md new file mode 100644 index 0000000..d3750ae --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-add-book-bottom-sheets-design.md @@ -0,0 +1,40 @@ +# Add Book Bottom Sheets Design + +## Goal + +Present both steps of the add-book flow as bottom sheets on every screen size and describe digital imports without emphasizing EPUB. + +## Presentation + +`AddBookChoiceSheet.show` and `ImportBookSheet.show` always use `showModalBottomSheet`. Remove their desktop-only dialog branches. + +- Use the root navigator so follow-up sheets appear above the application shell. +- Use the existing rounded top corners and standard bottom-sheet handle. +- Respect safe areas. +- Keep the choice sheet content-sized. +- Keep the import sheet content-sized in its idle state and scrollable when processing results, errors, or metadata previews increase its height. +- Do not reserve a fixed fraction of the viewport when the content does not require it. + +## Copy + +The digital import option and idle import state use format-neutral language: + +- Action: `Import digital books` +- Format list: `EPUB, PDF, AZW3, MOBI, CBZ/CBR` +- Prompt: `Select a digital book file` + +The offline-storage explanation and `Browse files` action remain unchanged. + +## Scope + +This change does not expand browser import support. The web file picker and importer continue accepting only EPUB until multi-format browser processing is implemented. Native import behavior is unchanged. + +## Verification + +Widget tests verify that: + +1. Both entry points use modal bottom sheets at desktop width. +2. Both sheets display the standard handle. +3. EPUB-specific prompts are absent. +4. The requested format list is visible. +5. The choice sheet still opens the import sheet and physical-book sheet correctly. From e0f39b0901141f6532ea6ea65e315c46dceee7ce Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 02:42:36 +0300 Subject: [PATCH 12/12] feat: implement bottom sheets for add book choice and import flows with improved layout --- app/lib/pages/book_edit_page.dart | 5 +- .../add_book/add_book_choice_sheet.dart | 30 +-- .../widgets/add_book/import_book_sheet.dart | 141 ++++++-------- .../widgets/shared/bottom_sheet_header.dart | 22 ++- .../pages/book_edit_page_layout_test.dart | 2 +- .../add_book/add_book_sheets_test.dart | 74 ++++++++ .../2026-07-13-add-book-bottom-sheets.md | 174 ++++++++++++++++++ .../2026-07-13-book-edit-responsive-pane.md | 2 +- 8 files changed, 331 insertions(+), 119 deletions(-) create mode 100644 app/test/widgets/add_book/add_book_sheets_test.dart create mode 100644 docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index b46d7b7..5892344 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -610,6 +610,8 @@ class _BookEditPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + searchField, + const SizedBox(height: Spacing.md), Wrap( spacing: Spacing.md, runSpacing: Spacing.sm, @@ -620,9 +622,6 @@ class _BookEditPageState extends State { ], ), - const SizedBox(height: Spacing.md), - searchField, - // Error message if (provider.fetchState == MetadataFetchState.error) ...[ const SizedBox(height: Spacing.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 51515c5..264fdd9 100644 --- a/app/lib/widgets/add_book/add_book_choice_sheet.dart +++ b/app/lib/widgets/add_book/add_book_choice_sheet.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/add_book/add_physical_book_sheet.dart'; @@ -15,29 +14,12 @@ class AddBookChoiceSheet extends StatelessWidget { /// dialog/bottom-sheet's own context becomes invalid after popping. final BuildContext callerContext; - /// Show the choice sheet (bottom sheet on mobile, dialog on desktop). + /// Show the choice sheet as a modal bottom sheet. static Future show(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - if (isDesktop) { - return showDialog( - context: context, - builder: (_) => Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.dialog)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Padding( - padding: const EdgeInsets.all(Spacing.lg), - child: AddBookChoiceSheet(callerContext: context), - ), - ), - ), - ); - } - return showModalBottomSheet( context: context, + 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), @@ -49,19 +31,19 @@ class AddBookChoiceSheet extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final isDesktop = MediaQuery.of(context).size.width >= Breakpoints.desktopSmall; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isDesktop) ...[const BottomSheetHandle(), const SizedBox(height: Spacing.lg)], + 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: kIsWeb ? 'EPUB' : 'EPUB, PDF, MOBI & more', + subtitle: 'EPUB, PDF, AZW3, MOBI, CBZ/CBR', onTap: () { Navigator.of(context).pop(); ImportBookSheet.show(callerContext); diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 7974787..2a70479 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -14,53 +14,27 @@ import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; import 'package:provider/provider.dart'; enum _ImportState { idle, processing, success, error } -/// Sheet for importing a digital book file (EPUB). +/// Sheet for importing a digital book file. /// /// 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}); - /// Show the import sheet (bottom sheet on mobile, dialog on desktop). + /// Show the import sheet as a scrollable, content-sized bottom sheet. static Future show(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - if (isDesktop) { - return showDialog( - context: context, - builder: (_) => Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.dialog)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: const Padding(padding: EdgeInsets.all(Spacing.lg), child: _ImportContent()), - ), - ), - ); - } - return showModalBottomSheet( context: context, isScrollControlled: true, useRootNavigator: true, + useSafeArea: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), - builder: (_) => DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) => SingleChildScrollView( - controller: scrollController, - child: const Padding( - padding: EdgeInsets.only(left: Spacing.lg, right: Spacing.lg, top: Spacing.md, bottom: Spacing.lg), - child: _ImportContent(), - ), - ), - ), + builder: (_) => const SingleChildScrollView(child: _ImportContent()), ); } @@ -76,6 +50,8 @@ class _ImportContent extends StatefulWidget { } class _ImportContentState extends State<_ImportContent> { + static const double _pendingContentHeight = 232; + final _importService = BookImportService(); _ImportState _state = _ImportState.idle; String? _filename; @@ -225,28 +201,30 @@ class _ImportContentState extends State<_ImportContent> { @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final isDesktop = MediaQuery.of(context).size.width >= Breakpoints.desktopSmall; - return Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isDesktop) const BottomSheetHandle(), - if (!isDesktop) const SizedBox(height: Spacing.lg), - Row( - children: [ - Expanded(child: Text('Import book', style: textTheme.headlineSmall)), - IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close)), - ], + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0), + child: Column( + children: [ + const BottomSheetHandle(), + const SizedBox(height: Spacing.md), + BottomSheetHeader(title: 'Import book', onCancel: () => Navigator.of(context).pop()), + ], + ), + ), + const SizedBox(height: Spacing.md), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.md), + child: switch (_state) { + _ImportState.idle => _buildIdleState(context), + _ImportState.processing => _buildProcessingState(context), + _ImportState.success => _buildSuccessState(context), + _ImportState.error => _buildErrorState(context), + }, ), - const SizedBox(height: Spacing.lg), - switch (_state) { - _ImportState.idle => _buildIdleState(context), - _ImportState.processing => _buildProcessingState(context), - _ImportState.success => _buildSuccessState(context), - _ImportState.error => _buildErrorState(context), - }, ], ); } @@ -255,35 +233,26 @@ class _ImportContentState extends State<_ImportContent> { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: Spacing.xl), - decoration: BoxDecoration( - border: Border.all(color: colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(AppRadius.lg), + return _buildPendingContent( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.upload_file, size: 48, color: colorScheme.primary), + const SizedBox(height: Spacing.md), + Text('Select a digital book file', style: textTheme.titleMedium), + const SizedBox(height: Spacing.xs), + Text( + 'EPUB, PDF, AZW3, MOBI, CBZ/CBR', + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), - child: Column( - children: [ - Icon(Icons.upload_file, size: 48, color: colorScheme.primary), - const SizedBox(height: Spacing.md), - Text(kIsWeb ? 'Select an EPUB file' : 'Select a book file', style: textTheme.titleMedium), - const SizedBox(height: Spacing.xs), - Text( - 'The file will be stored offline on this device', - style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: Spacing.lg), - FilledButton.icon( - onPressed: _committing ? null : _pickAndProcess, - icon: const Icon(Icons.folder_open), - label: const Text('Browse files'), - ), - ], + const SizedBox(height: Spacing.lg), + FilledButton.icon( + onPressed: _committing ? null : _pickAndProcess, + icon: const Icon(Icons.folder_open), + label: const Text('Browse files'), ), - ), - ], + ], + ), ); } @@ -291,14 +260,9 @@ class _ImportContentState extends State<_ImportContent> { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: Spacing.xl), - decoration: BoxDecoration( - border: Border.all(color: colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( + return _buildPendingContent( + Column( + mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 48, height: 48, child: CircularProgressIndicator()), const SizedBox(height: Spacing.lg), @@ -312,6 +276,15 @@ class _ImportContentState extends State<_ImportContent> { ); } + Widget _buildPendingContent(Widget child) { + return SizedBox( + key: const Key('import-pending-content'), + width: double.infinity, + height: _pendingContentHeight, + child: Center(child: child), + ); + } + Widget _buildSuccessState(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; diff --git a/app/lib/widgets/shared/bottom_sheet_header.dart b/app/lib/widgets/shared/bottom_sheet_header.dart index 6c5bccc..c1002a0 100644 --- a/app/lib/widgets/shared/bottom_sheet_header.dart +++ b/app/lib/widgets/shared/bottom_sheet_header.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; class BottomSheetHeader extends StatelessWidget { final String title; final VoidCallback onCancel; - final VoidCallback onSave; + final VoidCallback? onSave; final String saveLabel; final bool canSave; @@ -15,7 +15,7 @@ class BottomSheetHeader extends StatelessWidget { super.key, required this.title, required this.onCancel, - required this.onSave, + this.onSave, this.saveLabel = 'Save', this.canSave = true, }); @@ -24,11 +24,21 @@ class BottomSheetHeader extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - TextButton(onPressed: onCancel, child: const Text('Cancel')), - const Spacer(), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: TextButton(onPressed: onCancel, child: const Text('Cancel')), + ), + ), Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - const Spacer(), - FilledButton(onPressed: canSave ? onSave : null, child: Text(saveLabel)), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: onSave == null + ? const SizedBox.shrink() + : FilledButton(onPressed: canSave ? onSave : null, child: Text(saveLabel)), + ), + ), ], ); } diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart index cb4ba06..745457e 100644 --- a/app/test/pages/book_edit_page_layout_test.dart +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -117,7 +117,7 @@ void main() { expect(sourceLabel, findsOneWidget); expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); - expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getBottomLeft(searchField).dy)); + expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getTopLeft(searchField).dy)); expect((tester.getCenter(selector).dy - tester.getCenter(sourceLabel).dy).abs(), lessThan(1)); }); diff --git a/app/test/widgets/add_book/add_book_sheets_test.dart b/app/test/widgets/add_book/add_book_sheets_test.dart new file mode 100644 index 0000000..5c797f2 --- /dev/null +++ b/app/test/widgets/add_book/add_book_sheets_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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'; + +void main() { + Future pumpLauncher(WidgetTester tester, VoidCallback Function(BuildContext) action) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1400, 1000); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: FilledButton(onPressed: action(context), child: const Text('Open')), + ), + ), + ), + ); + } + + testWidgets('add book opens as a bottom sheet on desktop', (tester) async { + await pumpLauncher( + tester, + (context) => + () => AddBookChoiceSheet.show(context), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheetHandle), findsOneWidget); + expect(find.text('EPUB, PDF, AZW3, MOBI, CBZ/CBR'), findsOneWidget); + }); + + testWidgets('import book opens as a format-neutral bottom sheet on desktop', (tester) async { + await pumpLauncher( + tester, + (context) => + () => ImportBookSheet.show(context), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheetHandle), findsOneWidget); + expect(find.byType(BottomSheetHeader), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.byIcon(Icons.close), findsNothing); + expect(find.text('Select a digital book file'), findsOneWidget); + expect(find.text('EPUB, PDF, AZW3, MOBI, CBZ/CBR'), findsOneWidget); + expect(find.text('Select an EPUB file'), findsNothing); + expect(find.text('The file will be stored offline on this device'), findsNothing); + expect(find.byKey(const Key('import-pending-content')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('import-pending-content'))).height, 232); + expect( + find.ancestor( + of: find.text('Select a digital book file'), + matching: find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration! as BoxDecoration).border != null, + ), + ), + findsNothing, + ); + }); +} diff --git a/docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md b/docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md new file mode 100644 index 0000000..20f26e6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-add-book-bottom-sheets.md @@ -0,0 +1,174 @@ +# Add Book Bottom Sheets 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:** Present the add-book choice and digital import flows as content-sized bottom sheets on every screen and show the requested digital format list. + +**Architecture:** Remove desktop dialog branches from both sheet entry points and use the existing modal bottom-sheet components consistently. Keep import behavior unchanged; only presentation and explanatory copy change. + +**Tech Stack:** Flutter, Dart, Material modal bottom sheets, Flutter widget tests + +--- + +### Task 1: Capture desktop bottom-sheet behavior + +**Files:** +- Create: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [ ] **Step 1: Add a desktop test harness and failing choice-sheet test** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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'; + +void main() { + Future pumpLauncher(WidgetTester tester, VoidCallback Function(BuildContext) action) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1400, 1000); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: FilledButton(onPressed: action(context), child: const Text('Open')), + ), + ), + ), + ); + } + + testWidgets('add book opens as a bottom sheet on desktop', (tester) async { + await pumpLauncher(tester, (context) => () => AddBookChoiceSheet.show(context)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheetHandle), findsOneWidget); + expect(find.text('EPUB, PDF, AZW3, MOBI, CBZ/CBR'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Add the failing import-sheet test** + +```dart +testWidgets('import book opens as a format-neutral bottom sheet on desktop', (tester) async { + await pumpLauncher(tester, (context) => () => ImportBookSheet.show(context)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheetHandle), findsOneWidget); + expect(find.text('Select a digital book file'), findsOneWidget); + expect(find.text('EPUB, PDF, AZW3, MOBI, CBZ/CBR'), findsOneWidget); + expect(find.text('Select an EPUB file'), findsNothing); +}); +``` + +- [ ] **Step 3: Run the tests and verify both fail** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart +``` + +Expected: FAIL because desktop width currently opens `Dialog` widgets and the copy is EPUB-specific. + +### Task 2: Convert the choice dialog to a bottom sheet + +**Files:** +- Modify: `app/lib/widgets/add_book/add_book_choice_sheet.dart` +- Test: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [ ] **Step 1: Use one modal bottom-sheet entry point** + +Replace the desktop/mobile branching in `AddBookChoiceSheet.show` with: + +```dart +return showModalBottomSheet( + context: context, + 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), + ), +); +``` + +Always render `BottomSheetHandle`, remove the unused `foundation.dart` import, and set the digital option subtitle to `EPUB, PDF, AZW3, MOBI, CBZ/CBR`. + +- [ ] **Step 2: Run the focused choice-sheet test** + +Run: + +```bash +cd app +flutter test test/widgets/add_book/add_book_sheets_test.dart --plain-name "add book opens as a bottom sheet on desktop" +``` + +Expected: PASS. + +### Task 3: Convert the import dialog to a content-sized bottom sheet + +**Files:** +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` +- Test: `app/test/widgets/add_book/add_book_sheets_test.dart` + +- [ ] **Step 1: Replace dialog and draggable-sheet branches** + +Use one scrollable, content-sized bottom sheet: + +```dart +return showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => SingleChildScrollView( + child: const Padding( + padding: EdgeInsets.only( + left: Spacing.lg, + right: Spacing.lg, + top: Spacing.md, + bottom: Spacing.lg, + ), + child: _ImportContent(), + ), + ), +); +``` + +Always render `BottomSheetHandle`. Change the idle title to `Select a digital book file` and add `EPUB, PDF, AZW3, MOBI, CBZ/CBR` before the unchanged offline-storage explanation. Do not change `_webExtensions`, `_nativeExtensions`, or `BookImportService`. + +- [ ] **Step 2: Format and run focused verification** + +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 +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: both widget tests pass and analysis reports `No issues found!`. diff --git a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md index 9dba236..fc3a3ea 100644 --- a/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md +++ b/docs/superpowers/plans/2026-07-13-book-edit-responsive-pane.md @@ -191,7 +191,7 @@ testWidgets('metadata search precedes a compact visible source selector', (teste final sourceLabel = find.text('Source'); expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); - expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getBottomLeft(searchField).dy)); + expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getTopLeft(searchField).dy)); expect((tester.getCenter(selector).dy - tester.getCenter(sourceLabel).dy).abs(), lessThan(1)); }); ```