From dfe7ae181125430b5eec5005d5f80e3b73a32336 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 22 May 2026 08:33:24 -0400 Subject: [PATCH] feat: add multi-select groundwork to product publishing (Stage 3A) Add read-only multi-selection state to the product publishing workspace, preparing for future bulk actions without introducing any bulk writes. Controller (ProductPublishingController): - Add _multiSelectedIds Set for tracking multi-selected product IDs - Add toggleMultiSelect(id) to add/remove individual IDs - Add clearMultiSelection() to deselect all - Add selectAllVisible() to select all currently visible (filtered/searched) drafts - Add isMultiSelected(id), multiSelectedIds, multiSelectedCount, isMultiSelectActive getters - Multi-selection is independent of single-item preview selection - Multi-selection persists across load cycles and write operations UI (ProductDraftCard): - Add optional isMultiSelected/onMultiSelectToggle props - Show leading Checkbox when multi-select mode is active - Tapping checkbox toggles multi-select; tapping card body still fires single-item preview UI (ProductPublishingPage): - Add _MultiSelectBar widget above product list when multi-select is active - Shows selected count, Select All button, and Clear button - Replace deprecated withOpacity() calls with withValues(alpha:) Tests: - 15 new multi-select controller tests covering toggle, clear, select-all, filter/search interaction, independence from preview selection, persistence across loads and writes, and listener notifications - Total: 262 feature_wordpress tests passing Validation: - dart analyze: clean (0 issues) - flutter test: 262/262 passed Changed files: - lib/src/application/product_publishing_controller.dart - lib/src/presentation/widgets/product_draft_card.dart - lib/src/presentation/product_publishing_page.dart - test/product_publishing_controller_test.dart - docs/development/master_development_brief.md --- docs/development/master_development_brief.md | 29 +-- .../product_publishing_controller.dart | 40 ++++ .../presentation/product_publishing_page.dart | 60 ++++++ .../widgets/product_draft_card.dart | 112 ++++++---- .../product_publishing_controller_test.dart | 197 ++++++++++++++++++ 5 files changed, 380 insertions(+), 58 deletions(-) diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index c60656b..723525e 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -81,6 +81,7 @@ Rules: - ✅ Category-only product edit landed (Stage 1B complete — merged `feat/category-only-edit` → `main` at `8e7e4cb`, 2026-04-11). Stage 1 complete. - ✅ Post-write consistency hardening landed (Stage 2A complete — merged `feat/post-write-consistency` → `main` at `7acff83`, 2026-04-11). - ✅ Publishing workflow UX hardening landed (Stage 2B complete — merged `feat/publishing-ux-hardening` → `main` at `b81016d`, 2026-04-11). Stage 2 complete. +- ✅ Multi-select groundwork landed (Stage 3A complete — merged `feat/multi-select-groundwork` → `main`, 2026-05-22). ### Current narrow edit capabilities on `main` @@ -95,14 +96,14 @@ Rules: - `dart analyze` clean - `feature_wordpress` tests passing - `kell_web` dashboard tests passing -- latest reported count for `feature_wordpress`: `247/247 passed` +- latest reported count for `feature_wordpress`: `262/262 passed` - latest reported count for `kell_web` dashboard tests: `5/5 passed` -- baseline commit: `b81016d` (2026-04-11) +- baseline commit: merge of `feat/multi-select-groundwork` (2026-05-22) ### Next recommended branch -**`feat/multi-select-groundwork`** — Stage 3A: Multi-select groundwork (read/state only first). -Branch from `main` at `b81016d`. Stage 2 (operational hardening) is complete. +**`feat/list-efficiency-improvements`** — Stage 3B: List efficiency improvements. +Branch from latest `main`. Stage 3A (multi-select groundwork) is complete. --- @@ -184,24 +185,10 @@ Increase throughput for product triage and management using existing data. - `feat/multi-select-groundwork` - `feat/list-efficiency-improvements` -#### Stage 3A — Multi-select groundwork (read/state only first) +#### ~~Stage 3A — Multi-select groundwork (read/state only first)~~ ✅ COMPLETE -##### Goal - -Prepare for future bulk actions without implementing bulk writes yet. - -##### Requirements - -- add selection model for multiple items -- show selected-count UI -- allow clear selection -- preserve current single-item preview behavior where appropriate -- do not add bulk publish/edit/delete actions yet - -##### Definition of done - -- multi-select state exists and is tested -- no bulk writes introduced +> Merged `feat/multi-select-groundwork` → `main` (2026-05-22). +> Added multi-selection state to `ProductPublishingController` (`toggleMultiSelect`, `clearMultiSelection`, `selectAllVisible`, `isMultiSelected`, `multiSelectedIds`, `multiSelectedCount`). Updated `ProductDraftCard` with optional leading checkbox for multi-select mode. Added `_MultiSelectBar` to `ProductPublishingPage` with selected-count display, Select All, and Clear actions. Single-item preview selection preserved independently. 15 new multi-select tests added (262 total `feature_wordpress` tests passing). No bulk writes introduced. #### Stage 3B — List efficiency improvements diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart index d340fe3..a0976d7 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart @@ -157,6 +157,46 @@ class ProductPublishingController extends ChangeNotifier { /// Whether the current sort is ascending (`true`) or descending (`false`). bool sortAscending = true; + /// Product IDs that the operator has selected for potential bulk actions. + /// + /// Multi-select is independent of the single-item [selectedDraft] preview. + /// Adding or removing items here does not change which product is shown + /// in the detail panel. + final Set multiSelectedIds = {}; + + /// The number of products currently multi-selected. + int get multiSelectedCount => multiSelectedIds.length; + + /// Whether multi-select mode is active (at least one item selected). + bool get isMultiSelectActive => multiSelectedIds.isNotEmpty; + + /// Whether the product with [id] is part of the multi-selection. + bool isMultiSelected(String id) => multiSelectedIds.contains(id); + + /// Toggles the multi-select state of the product with [id]. + /// + /// If the product is already selected it is removed; otherwise it is added. + void toggleMultiSelect(String id) { + if (multiSelectedIds.contains(id)) { + multiSelectedIds.remove(id); + } else { + multiSelectedIds.add(id); + } + _safeNotify(); + } + + /// Selects all currently visible products (those in [drafts]). + void selectAllVisible() { + multiSelectedIds.addAll(drafts.map((d) => d.id)); + _safeNotify(); + } + + /// Clears the entire multi-selection. + void clearMultiSelection() { + multiSelectedIds.clear(); + _safeNotify(); + } + /// Product IDs that currently have an in-flight status update. /// /// Used to prevent duplicate clicks and to let the UI show per-row diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart index 4d6449a..6ae2290 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart @@ -204,6 +204,14 @@ class _ProductPublishingPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ── Multi-select action bar ────────────────────────────────── + if (controller.isMultiSelectActive) + _MultiSelectBar( + selectedCount: controller.multiSelectedCount, + totalCount: count, + onSelectAll: controller.selectAllVisible, + onClearSelection: controller.clearMultiSelection, + ), Padding( padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm), child: Text( @@ -223,6 +231,8 @@ class _ProductPublishingPageState extends State { draft: draft, isSelected: draft.id == controller.selectedDraft?.id, onTap: () => controller.selectDraft(draft), + isMultiSelected: controller.isMultiSelected(draft.id), + onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id), ), ); }, @@ -262,3 +272,53 @@ class _ProductPublishingPageState extends State { ); } } + +/// A compact action bar shown above the product list when multi-select is +/// active. Displays the count of selected items and provides Select All / +/// Clear Selection actions. +/// +/// No bulk-write actions are included — this is groundwork only. +class _MultiSelectBar extends StatelessWidget { + final int selectedCount; + final int totalCount; + final VoidCallback onSelectAll; + final VoidCallback onClearSelection; + + const _MultiSelectBar({ + required this.selectedCount, + required this.totalCount, + required this.onSelectAll, + required this.onClearSelection, + }); + + @override + Widget build(BuildContext context) { + final allSelected = selectedCount == totalCount; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: KcSpacing.sm, vertical: KcSpacing.xs), + margin: const EdgeInsets.only(bottom: KcSpacing.sm), + decoration: BoxDecoration( + color: KcColors.denimBlue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: KcColors.denimBlue.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(Icons.checklist, size: 18, color: KcColors.denimBlue), + const SizedBox(width: KcSpacing.xs), + Text( + '$selectedCount selected', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')), + TextButton(onPressed: onClearSelection, child: const Text('Clear')), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart index 0e5b74f..9d88608 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart @@ -7,16 +7,36 @@ import 'publish_status_chip.dart'; /// A card displaying a summary of a [ProductDraft]. /// /// Shows the product name, SKU, price, category, and publish status. -/// Highlights when [isSelected] is true. +/// Highlights when [isSelected] is true (single-item preview selection). +/// +/// When [isMultiSelected] is non-null, a leading checkbox is shown to +/// support multi-select mode. Tapping the checkbox fires [onMultiSelectToggle]; +/// tapping the rest of the card still fires [onTap] for single-item preview. class ProductDraftCard extends StatelessWidget { final ProductDraft draft; final bool isSelected; final VoidCallback? onTap; - const ProductDraftCard({super.key, required this.draft, this.isSelected = false, this.onTap}); + /// Whether this card is part of the multi-selection. + /// When `null`, the checkbox is hidden (multi-select not active). + final bool? isMultiSelected; + + /// Called when the multi-select checkbox is toggled. + final VoidCallback? onMultiSelectToggle; + + const ProductDraftCard({ + super.key, + required this.draft, + this.isSelected = false, + this.onTap, + this.isMultiSelected, + this.onMultiSelectToggle, + }); @override Widget build(BuildContext context) { + final showCheckbox = isMultiSelected != null; + return GestureDetector( onTap: onTap, child: AnimatedContainer( @@ -33,43 +53,61 @@ class ProductDraftCard extends StatelessWidget { BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), ], ), - child: Column( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - draft.name, - style: Theme.of(context).textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: KcSpacing.xs), - Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: KcSpacing.sm), - Row( - children: [ - Text( - '\$${draft.price.toStringAsFixed(2)}', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(width: KcSpacing.sm), - Text( - draft.category, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral), - ), - ], - ), - const Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - PublishStatusChip(status: draft.status), - Text( - '${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}', - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), - ), - ], + if (showCheckbox) ...[ + Checkbox( + value: isMultiSelected ?? false, + onChanged: (_) => onMultiSelectToggle?.call(), + ), + const SizedBox(width: KcSpacing.xs), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + draft.name, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: KcSpacing.xs), + Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: KcSpacing.sm), + Row( + children: [ + Text( + '\$${draft.price.toStringAsFixed(2)}', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(width: KcSpacing.sm), + Text( + draft.category, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: KcColors.neutral), + ), + ], + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PublishStatusChip(status: draft.status), + Text( + '${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: KcColors.neutral), + ), + ], + ), + ], + ), ), ], ), diff --git a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart index 0e63e23..6d38ef8 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart @@ -1017,6 +1017,203 @@ void main() { }); }); + // ── Multi-select groundwork ───────────────────────────────────────── + + group('multiSelect', () { + test('starts with empty multi-selection', () { + expect(controller.multiSelectedIds, isEmpty); + expect(controller.multiSelectedCount, 0); + expect(controller.isMultiSelectActive, false); + }); + + test('toggleMultiSelect adds an id', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + + expect(controller.isMultiSelected('1'), true); + expect(controller.multiSelectedCount, 1); + expect(controller.isMultiSelectActive, true); + }); + + test('toggleMultiSelect removes an already-selected id', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + expect(controller.isMultiSelected('1'), true); + + controller.toggleMultiSelect('1'); + expect(controller.isMultiSelected('1'), false); + expect(controller.multiSelectedCount, 0); + expect(controller.isMultiSelectActive, false); + }); + + test('multiple ids can be multi-selected', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('3'); + controller.toggleMultiSelect('5'); + + expect(controller.multiSelectedCount, 3); + expect(controller.isMultiSelected('1'), true); + expect(controller.isMultiSelected('3'), true); + expect(controller.isMultiSelected('5'), true); + expect(controller.isMultiSelected('2'), false); + }); + + test('clearMultiSelection removes all multi-selected ids', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + controller.toggleMultiSelect('3'); + expect(controller.multiSelectedCount, 3); + + controller.clearMultiSelection(); + + expect(controller.multiSelectedIds, isEmpty); + expect(controller.multiSelectedCount, 0); + expect(controller.isMultiSelectActive, false); + }); + + test('selectAllVisible selects all visible draft ids', () async { + await controller.load(); + + controller.selectAllVisible(); + + expect(controller.multiSelectedCount, 6); + for (final draft in controller.drafts) { + expect(controller.isMultiSelected(draft.id), true); + } + }); + + test('selectAllVisible respects active filter', () async { + await controller.load(); + + controller.setFilter('draft'); + expect(controller.drafts.length, 2); + + controller.selectAllVisible(); + + expect(controller.multiSelectedCount, 2); + expect(controller.isMultiSelected('4'), true); + expect(controller.isMultiSelected('5'), true); + // Non-visible ids should not be selected. + expect(controller.isMultiSelected('1'), false); + }); + + test('multi-select is independent of single-item preview selection', () async { + await controller.load(); + + // Multi-select two items. + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('3'); + + // Single-select a different item for preview. + controller.selectDraft(controller.drafts.firstWhere((d) => d.id == '5')); + + // Preview selection should be independent. + expect(controller.selectedDraft!.id, '5'); + expect(controller.isMultiSelected('5'), false); + expect(controller.multiSelectedCount, 2); + }); + + test('multi-selection persists across load cycles', () async { + await controller.load(); + + controller.toggleMultiSelect('2'); + controller.toggleMultiSelect('4'); + + await controller.load(); + + expect(controller.isMultiSelected('2'), true); + expect(controller.isMultiSelected('4'), true); + expect(controller.multiSelectedCount, 2); + }); + + test('multi-selection persists after write operations', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('4'); + + // Update price on product 4. + await controller.updatePrice('4', 20.00); + + // Multi-selection should still be intact. + expect(controller.isMultiSelected('1'), true); + expect(controller.isMultiSelected('4'), true); + expect(controller.multiSelectedCount, 2); + }); + + test('toggleMultiSelect notifies listeners', () async { + await controller.load(); + + int notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.toggleMultiSelect('1'); + expect(notifyCount, 1); + + controller.toggleMultiSelect('1'); + expect(notifyCount, 2); + }); + + test('clearMultiSelection notifies listeners', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + + int notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.clearMultiSelection(); + expect(notifyCount, 1); + }); + + test('selectAllVisible notifies listeners', () async { + await controller.load(); + + int notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.selectAllVisible(); + expect(notifyCount, 1); + }); + + test('selectAllVisible with search selects only matching products', () async { + await controller.load(); + + controller.setSearchQuery('coaster'); + expect(controller.drafts.length, 2); + + controller.selectAllVisible(); + + expect(controller.multiSelectedCount, 2); + expect(controller.isMultiSelected('2'), true); // Citrus Coaster Set + expect(controller.isMultiSelected('6'), true); // Sublimated Slate Coaster + expect(controller.isMultiSelected('1'), false); + }); + + test('selectAllVisible is additive to existing selection', () async { + await controller.load(); + + // Select one product manually. + controller.toggleMultiSelect('1'); + + // Filter to drafts only and select all visible. + controller.setFilter('draft'); + controller.selectAllVisible(); + + // Should have the manually selected + the two drafts. + expect(controller.isMultiSelected('1'), true); + expect(controller.isMultiSelected('4'), true); + expect(controller.isMultiSelected('5'), true); + expect(controller.multiSelectedCount, 3); + }); + }); + group('disposed guard', () { test('load does not notify after disposal', () async { // Start load, then immediately dispose.