Compare commits

..

No commits in common. "f3fbbca06d1945e1d7c1855cbece7e7b2ae1dccc" and "49a3702cecac8cd5e35f8821a72c4403e58b5316" have entirely different histories.

5 changed files with 58 additions and 380 deletions

View File

@ -81,7 +81,6 @@ 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`
@ -96,14 +95,14 @@ Rules:
- `dart analyze` clean
- `feature_wordpress` tests passing
- `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `262/262 passed`
- latest reported count for `feature_wordpress`: `247/247 passed`
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline commit: merge of `feat/multi-select-groundwork` (2026-05-22)
- baseline commit: `b81016d` (2026-04-11)
### Next recommended branch
**`feat/list-efficiency-improvements`** — Stage 3B: List efficiency improvements.
Branch from latest `main`. Stage 3A (multi-select groundwork) is complete.
**`feat/multi-select-groundwork`** — Stage 3A: Multi-select groundwork (read/state only first).
Branch from `main` at `b81016d`. Stage 2 (operational hardening) is complete.
---
@ -185,10 +184,24 @@ 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)~~ ✅ COMPLETE
#### Stage 3A — Multi-select groundwork (read/state only first)
> 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.
##### 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
#### Stage 3B — List efficiency improvements

View File

@ -157,46 +157,6 @@ 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<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.
///
/// Used to prevent duplicate clicks and to let the UI show per-row

View File

@ -204,14 +204,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
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(
@ -231,8 +223,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
draft: draft,
isSelected: draft.id == controller.selectedDraft?.id,
onTap: () => controller.selectDraft(draft),
isMultiSelected: controller.isMultiSelected(draft.id),
onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id),
),
);
},
@ -272,53 +262,3 @@ 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,36 +7,16 @@ 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 (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.
/// Highlights when [isSelected] is true.
class ProductDraftCard extends StatelessWidget {
final ProductDraft draft;
final bool isSelected;
final VoidCallback? 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,
});
const ProductDraftCard({super.key, required this.draft, this.isSelected = false, this.onTap});
@override
Widget build(BuildContext context) {
final showCheckbox = isMultiSelected != null;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
@ -53,61 +33,43 @@ class ProductDraftCard extends StatelessWidget {
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
],
),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
),
],
),
],
),
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),
),
],
),
],
),

View File

@ -1017,203 +1017,6 @@ 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.