Compare commits

...

2 Commits

Author SHA1 Message Date
Mike Kell f3fbbca06d Merge feat/multi-select-groundwork into main (Stage 3A complete)
Publish Docs / publish-docs (push) Successful in 1m26s Details
2026-05-22 08:33:32 -04:00
Mike Kell dfe7ae1811 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<String> 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
2026-05-22 08:33:24 -04:00
5 changed files with 380 additions and 58 deletions

View File

@ -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. - ✅ 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). - ✅ 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. - ✅ 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` ### Current narrow edit capabilities on `main`
@ -95,14 +96,14 @@ Rules:
- `dart analyze` clean - `dart analyze` clean
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `kell_web` dashboard 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` - 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 ### Next recommended branch
**`feat/multi-select-groundwork`** — Stage 3A: Multi-select groundwork (read/state only first). **`feat/list-efficiency-improvements`** — Stage 3B: List efficiency improvements.
Branch from `main` at `b81016d`. Stage 2 (operational hardening) is complete. 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/multi-select-groundwork`
- `feat/list-efficiency-improvements` - `feat/list-efficiency-improvements`
#### Stage 3A — Multi-select groundwork (read/state only first) #### ~~Stage 3A — Multi-select groundwork (read/state only first)~~ ✅ COMPLETE
##### Goal > 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.
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
#### Stage 3B — List efficiency improvements #### Stage 3B — List efficiency improvements

View File

@ -157,6 +157,46 @@ class ProductPublishingController extends ChangeNotifier {
/// Whether the current sort is ascending (`true`) or descending (`false`). /// Whether the current sort is ascending (`true`) or descending (`false`).
bool sortAscending = true; 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<String> 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. /// Product IDs that currently have an in-flight status update.
/// ///
/// Used to prevent duplicate clicks and to let the UI show per-row /// Used to prevent duplicate clicks and to let the UI show per-row

View File

@ -204,6 +204,14 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Multi-select action bar
if (controller.isMultiSelectActive)
_MultiSelectBar(
selectedCount: controller.multiSelectedCount,
totalCount: count,
onSelectAll: controller.selectAllVisible,
onClearSelection: controller.clearMultiSelection,
),
Padding( Padding(
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm), padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
child: Text( child: Text(
@ -223,6 +231,8 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
draft: draft, draft: draft,
isSelected: draft.id == controller.selectedDraft?.id, isSelected: draft.id == controller.selectedDraft?.id,
onTap: () => controller.selectDraft(draft), onTap: () => controller.selectDraft(draft),
isMultiSelected: controller.isMultiSelected(draft.id),
onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id),
), ),
); );
}, },
@ -262,3 +272,53 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
); );
} }
} }
/// 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')),
],
),
);
}
}

View File

@ -7,16 +7,36 @@ import 'publish_status_chip.dart';
/// A card displaying a summary of a [ProductDraft]. /// A card displaying a summary of a [ProductDraft].
/// ///
/// Shows the product name, SKU, price, category, and publish status. /// 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 { class ProductDraftCard extends StatelessWidget {
final ProductDraft draft; final ProductDraft draft;
final bool isSelected; final bool isSelected;
final VoidCallback? onTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showCheckbox = isMultiSelected != null;
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
@ -33,43 +53,61 @@ class ProductDraftCard extends StatelessWidget {
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
], ],
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( if (showCheckbox) ...[
draft.name, Checkbox(
style: Theme.of(context).textTheme.titleLarge, value: isMultiSelected ?? false,
maxLines: 1, onChanged: (_) => onMultiSelectToggle?.call(),
overflow: TextOverflow.ellipsis, ),
), const SizedBox(width: KcSpacing.xs),
const SizedBox(height: KcSpacing.xs), ],
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium), Expanded(
const SizedBox(height: KcSpacing.sm), child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'\$${draft.price.toStringAsFixed(2)}', draft.name,
style: Theme.of( style: Theme.of(context).textTheme.titleLarge,
context, maxLines: 1,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis,
), ),
const SizedBox(width: KcSpacing.sm), const SizedBox(height: KcSpacing.xs),
Text( Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
draft.category, const SizedBox(height: KcSpacing.sm),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral), Row(
), children: [
], Text(
), '\$${draft.price.toStringAsFixed(2)}',
const Spacer(), style: Theme.of(
Row( context,
mainAxisAlignment: MainAxisAlignment.spaceBetween, ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
children: [ ),
PublishStatusChip(status: draft.status), const SizedBox(width: KcSpacing.sm),
Text( Text(
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}', draft.category,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), 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),
),
],
),
],
),
), ),
], ],
), ),

View File

@ -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', () { group('disposed guard', () {
test('load does not notify after disposal', () async { test('load does not notify after disposal', () async {
// Start load, then immediately dispose. // Start load, then immediately dispose.