Skip to content

Commit 6bcdcba

Browse files
committed
feat: Implement book multi-selection and bulk actions,
1 parent 92172ec commit 6bcdcba

14 files changed

Lines changed: 1151 additions & 335 deletions

client/lib/pages/library_page.dart

Lines changed: 156 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
23
import 'package:go_router/go_router.dart';
34
import 'package:papyrus/data/data_store.dart';
45
import 'package:papyrus/models/active_filter.dart';
56
import 'package:papyrus/models/book.dart';
67
import 'package:papyrus/providers/library_provider.dart';
78
import 'package:papyrus/themes/design_tokens.dart';
9+
import 'package:papyrus/utils/bulk_book_actions.dart';
810
import 'package:papyrus/utils/search_query_parser.dart';
911
import 'package:papyrus/widgets/filter/filter_bottom_sheet.dart';
1012
import 'package:papyrus/widgets/filter/filter_dialog.dart';
1113
import 'package:papyrus/widgets/library/book_grid.dart';
1214
import 'package:papyrus/widgets/library/book_list_item.dart';
1315
import 'package:papyrus/widgets/library/library_drawer.dart';
1416
import 'package:papyrus/widgets/library/library_filter_chips.dart';
17+
import 'package:papyrus/widgets/library/selection_header.dart';
1518
import 'package:papyrus/widgets/search/library_search_bar.dart';
1619
import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart';
1720
import 'package:papyrus/widgets/shared/empty_state.dart';
@@ -113,61 +116,74 @@ class _LibraryPageState extends State<LibraryPage> {
113116
List<Book> books,
114117
LibraryProvider libraryProvider,
115118
) {
119+
final isSelectionMode = libraryProvider.isSelectionMode;
120+
116121
return Scaffold(
117122
key: _scaffoldKey,
118123
drawer: const LibraryDrawer(),
119124
body: SafeArea(
120125
child: Column(
121126
children: [
122-
// Search bar section with drawer button
127+
// Header: selection header or normal header
123128
Padding(
124129
padding: const EdgeInsets.only(
125130
top: Spacing.md,
126131
left: Spacing.md,
127132
right: Spacing.md,
128133
),
129-
child: Row(
130-
children: [
131-
// Drawer hamburger button
132-
IconButton(
133-
icon: const Icon(Icons.menu),
134-
onPressed: () {
135-
_scaffoldKey.currentState?.openDrawer();
136-
},
137-
tooltip: 'Library sections',
138-
),
139-
const SizedBox(width: Spacing.xs),
140-
// Search bar
141-
Expanded(child: _buildSearchBar(libraryProvider)),
142-
const SizedBox(width: Spacing.sm),
143-
_buildSortButton(libraryProvider),
144-
],
145-
),
134+
child: isSelectionMode
135+
? SelectionHeader(
136+
selectedCount: libraryProvider.selectedCount,
137+
totalCount: books.length,
138+
onClose: libraryProvider.exitSelectionMode,
139+
onSelectAll: () => libraryProvider.selectAll(
140+
books.map((b) => b.id).toList(),
141+
),
142+
onDeselectAll: libraryProvider.deselectAll,
143+
)
144+
: Row(
145+
children: [
146+
// Drawer hamburger button
147+
IconButton(
148+
icon: const Icon(Icons.menu),
149+
onPressed: () {
150+
_scaffoldKey.currentState?.openDrawer();
151+
},
152+
tooltip: 'Library sections',
153+
),
154+
const SizedBox(width: Spacing.xs),
155+
// Search bar
156+
Expanded(child: _buildSearchBar(libraryProvider)),
157+
const SizedBox(width: Spacing.sm),
158+
_buildSortButton(libraryProvider),
159+
],
160+
),
146161
),
147162

148163
// Quick filter chips
149164
const LibraryFilterChips(),
150165

151166
// View toggle row
152-
Padding(
153-
padding: const EdgeInsets.only(
154-
left: Spacing.md,
155-
right: Spacing.md,
156-
bottom: Spacing.md,
157-
),
158-
child: Row(
159-
mainAxisAlignment: MainAxisAlignment.spaceBetween,
160-
children: [
161-
Text(
162-
'${books.length} ${books.length == 1 ? 'book' : 'books'}',
163-
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
164-
color: Theme.of(context).colorScheme.onSurfaceVariant,
167+
if (!isSelectionMode)
168+
Padding(
169+
padding: const EdgeInsets.only(
170+
left: Spacing.md,
171+
right: Spacing.md,
172+
bottom: Spacing.md,
173+
),
174+
child: Row(
175+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
176+
children: [
177+
Text(
178+
'${books.length} ${books.length == 1 ? 'book' : 'books'}',
179+
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
180+
color: Theme.of(context).colorScheme.onSurfaceVariant,
181+
),
165182
),
166-
),
167-
_buildViewToggle(libraryProvider),
168-
],
183+
_buildViewToggle(libraryProvider),
184+
],
185+
),
169186
),
170-
),
171187

172188
// Book grid or list
173189
Expanded(
@@ -184,10 +200,15 @@ class _LibraryPageState extends State<LibraryPage> {
184200
],
185201
),
186202
),
187-
floatingActionButton: FloatingActionButton(
188-
onPressed: () => AddBookChoiceSheet.show(context),
189-
child: const Icon(Icons.add),
190-
),
203+
floatingActionButton: isSelectionMode
204+
? null
205+
: FloatingActionButton(
206+
onPressed: () => AddBookChoiceSheet.show(context),
207+
child: const Icon(Icons.add),
208+
),
209+
bottomNavigationBar: isSelectionMode
210+
? buildMobileBottomActionBar(context, libraryProvider)
211+
: null,
191212
);
192213
}
193214

@@ -484,80 +505,108 @@ class _LibraryPageState extends State<LibraryPage> {
484505
LibraryProvider libraryProvider,
485506
) {
486507
const double controlHeight = 40.0;
508+
final isSelectionMode = libraryProvider.isSelectionMode;
487509

488-
return Scaffold(
489-
body: Column(
490-
crossAxisAlignment: CrossAxisAlignment.start,
491-
children: [
492-
// Header row
493-
Container(
494-
padding: const EdgeInsets.only(
495-
top: Spacing.lg,
496-
left: Spacing.lg,
497-
right: Spacing.lg,
498-
),
499-
child: LayoutBuilder(
500-
builder: (context, constraints) {
501-
final useCompactLayout = constraints.maxWidth < 800;
502-
503-
if (useCompactLayout) {
504-
return Column(
505-
crossAxisAlignment: CrossAxisAlignment.stretch,
506-
children: [
507-
Row(
508-
children: [
509-
Expanded(child: _buildSearchBar(libraryProvider)),
510-
const SizedBox(width: Spacing.sm),
511-
_buildSortButton(libraryProvider),
512-
],
510+
return CallbackShortcuts(
511+
bindings: {
512+
const SingleActivator(LogicalKeyboardKey.escape): () {
513+
if (libraryProvider.isSelectionMode) {
514+
libraryProvider.exitSelectionMode();
515+
}
516+
},
517+
},
518+
child: Focus(
519+
autofocus: true,
520+
child: Scaffold(
521+
body: Column(
522+
crossAxisAlignment: CrossAxisAlignment.start,
523+
children: [
524+
// Header row
525+
Container(
526+
padding: const EdgeInsets.only(
527+
top: Spacing.lg,
528+
left: Spacing.lg,
529+
right: Spacing.lg,
530+
),
531+
child: isSelectionMode
532+
? SelectionHeader(
533+
selectedCount: libraryProvider.selectedCount,
534+
totalCount: books.length,
535+
onClose: libraryProvider.exitSelectionMode,
536+
onSelectAll: () => libraryProvider.selectAll(
537+
books.map((b) => b.id).toList(),
538+
),
539+
onDeselectAll: libraryProvider.deselectAll,
540+
actions: buildBulkActionBar(context, libraryProvider),
541+
)
542+
: LayoutBuilder(
543+
builder: (context, constraints) {
544+
final useCompactLayout = constraints.maxWidth < 800;
545+
546+
if (useCompactLayout) {
547+
return Column(
548+
crossAxisAlignment: CrossAxisAlignment.stretch,
549+
children: [
550+
Row(
551+
children: [
552+
Expanded(
553+
child: _buildSearchBar(libraryProvider),
554+
),
555+
const SizedBox(width: Spacing.sm),
556+
_buildSortButton(libraryProvider),
557+
],
558+
),
559+
const SizedBox(height: Spacing.md),
560+
Row(
561+
children: [
562+
const Spacer(),
563+
_buildViewToggle(libraryProvider),
564+
const SizedBox(width: Spacing.sm),
565+
_buildAddBookButton(controlHeight),
566+
],
567+
),
568+
],
569+
);
570+
}
571+
572+
return Row(
573+
children: [
574+
Expanded(child: _buildSearchBar(libraryProvider)),
575+
const SizedBox(width: Spacing.md),
576+
_buildSortButton(libraryProvider),
577+
const SizedBox(width: Spacing.md),
578+
_buildViewToggle(libraryProvider),
579+
const SizedBox(width: Spacing.md),
580+
_buildAddBookButton(controlHeight),
581+
],
582+
);
583+
},
513584
),
514-
const SizedBox(height: Spacing.md),
515-
Row(
516-
children: [
517-
const Spacer(),
518-
_buildViewToggle(libraryProvider),
519-
const SizedBox(width: Spacing.sm),
520-
_buildAddBookButton(controlHeight),
521-
],
585+
),
586+
// Filter chips
587+
const LibraryFilterChips(horizontalPadding: Spacing.lg),
588+
// Book grid or list
589+
Expanded(
590+
child: books.isEmpty
591+
? _buildEmptyState()
592+
: libraryProvider.isListView
593+
? _buildBookList(context, books)
594+
: BookGrid(
595+
books: books,
596+
onBookTap: (book) =>
597+
_navigateToBookDetails(context, book),
522598
),
523-
],
524-
);
525-
}
526-
527-
return Row(
528-
children: [
529-
Expanded(child: _buildSearchBar(libraryProvider)),
530-
const SizedBox(width: Spacing.md),
531-
_buildSortButton(libraryProvider),
532-
const SizedBox(width: Spacing.md),
533-
_buildViewToggle(libraryProvider),
534-
const SizedBox(width: Spacing.md),
535-
_buildAddBookButton(controlHeight),
536-
],
537-
);
538-
},
539-
),
540-
),
541-
// Filter chips
542-
const LibraryFilterChips(horizontalPadding: Spacing.lg),
543-
// Book grid or list
544-
Expanded(
545-
child: books.isEmpty
546-
? _buildEmptyState()
547-
: libraryProvider.isListView
548-
? _buildBookList(context, books)
549-
: BookGrid(
550-
books: books,
551-
onBookTap: (book) => _navigateToBookDetails(context, book),
552-
),
599+
),
600+
],
553601
),
554-
],
602+
),
555603
),
556604
);
557605
}
558606

559607
Widget _buildBookList(BuildContext context, List<Book> books) {
560-
final libraryProvider = context.read<LibraryProvider>();
608+
final libraryProvider = context.watch<LibraryProvider>();
609+
final isSelectionMode = libraryProvider.isSelectionMode;
561610

562611
return ListView.builder(
563612
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
@@ -572,6 +621,9 @@ class _LibraryPageState extends State<LibraryPage> {
572621
book: book,
573622
isFavorite: isFavorite,
574623
onTap: () => _navigateToBookDetails(context, book),
624+
isSelectionMode: isSelectionMode,
625+
isSelected: libraryProvider.isBookSelected(book.id),
626+
onSelectToggle: () => libraryProvider.toggleBookSelection(book.id),
575627
);
576628
},
577629
);

0 commit comments

Comments
 (0)