From a0aea373c2dde95e006f5f0a4f91fa064781fe3f Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 22 May 2026 08:51:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(wordpress):=20Stage=203B=20=E2=80=94=20lis?= =?UTF-8?q?t=20efficiency=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add compact/standard view toggle (ListDensity enum) to controller and page, allowing users to switch between a dense single-row layout and the full metadata card view. Add staleness detection: isStale() flags products not modified in 30+ days with a schedule icon; staleCount() returns the count of stale items in the current filtered view. Add keyboard navigation: selectNextDraft/selectPreviousDraft with arrow-key wrapping support; page wires up Focus + onKeyEvent for arrow-down/arrow-up. Update ProductDraftCard with compact layout variant (two-row dense metadata), stale indicator icon, and description snippet in standard mode. Wrap long text in Flexible widgets to prevent overflow. Increase standard card height from 160px to 180px to accommodate the new description snippet row. Export ListDensity and ProductSortField from barrel file. Add 42 new focused tests covering listDensity toggle, staleness boundary conditions, staleCount with filters, keyboard navigation wrapping, and compact card rendering. All 294 tests pass, dart analyze clean. --- .../lib/feature_wordpress.dart | 2 +- .../product_publishing_controller.dart | 78 ++++++ .../presentation/product_publishing_page.dart | 117 ++++++--- .../widgets/product_draft_card.dart | 187 ++++++++++---- .../product_publishing_controller_test.dart | 230 ++++++++++++++++++ .../test/widgets/product_draft_card_test.dart | 71 +++++- 6 files changed, 599 insertions(+), 86 deletions(-) diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 6260d41..d733ba3 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -12,7 +12,7 @@ export 'src/domain/product_publishing_repository.dart'; export 'src/domain/publish_status.dart'; // Application -export 'src/application/product_publishing_controller.dart' show ProductSortField; +export 'src/application/product_publishing_controller.dart' show ListDensity, ProductSortField; export 'src/application/update_product_category.dart'; export 'src/application/update_product_description.dart'; export 'src/application/update_product_name.dart'; 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 a0976d7..480be93 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 @@ -10,6 +10,15 @@ import 'update_product_name.dart'; import 'update_product_price.dart'; import 'update_product_status.dart'; +/// The display density for the product list. +enum ListDensity { + /// Standard card height with full metadata. + standard, + + /// Compact card height with condensed metadata for faster scanning. + compact, +} + /// The field used to sort the visible product list. enum ProductSortField { /// Sort alphabetically by product name. @@ -157,6 +166,15 @@ class ProductPublishingController extends ChangeNotifier { /// Whether the current sort is ascending (`true`) or descending (`false`). bool sortAscending = true; + /// The current display density for the product list. + ListDensity listDensity = ListDensity.standard; + + /// The number of days after which a product is considered stale. + /// + /// Products whose [ProductDraft.lastModified] is older than this many days + /// from [DateTime.now] are flagged with a visual indicator. + static const int staleDays = 30; + /// Product IDs that the operator has selected for potential bulk actions. /// /// Multi-select is independent of the single-item [selectedDraft] preview. @@ -197,6 +215,66 @@ class ProductPublishingController extends ChangeNotifier { _safeNotify(); } + /// Toggles the list density between [ListDensity.standard] and + /// [ListDensity.compact]. + void toggleListDensity() { + listDensity = listDensity == ListDensity.standard ? ListDensity.compact : ListDensity.standard; + _safeNotify(); + } + + /// Whether the given [draft] is stale (not modified within [staleDays]). + /// + /// Accepts an optional [now] parameter for testability; defaults to + /// [DateTime.now]. + bool isStale(ProductDraft draft, {DateTime? now}) { + final reference = now ?? DateTime.now(); + return reference.difference(draft.lastModified).inDays >= staleDays; + } + + /// The number of currently visible products that are stale. + /// + /// Accepts an optional [now] parameter for testability. + int staleCount({DateTime? now}) { + final reference = now ?? DateTime.now(); + return drafts.where((d) => isStale(d, now: reference)).length; + } + + /// Selects the next draft in the visible list relative to the current + /// [selectedDraft]. Wraps to the first item at the end of the list. + /// + /// Returns `true` if the selection changed. + bool selectNextDraft() { + if (drafts.isEmpty) return false; + if (selectedDraft == null) { + selectedDraft = drafts.first; + _safeNotify(); + return true; + } + final currentIndex = drafts.indexWhere((d) => d.id == selectedDraft!.id); + final nextIndex = (currentIndex + 1) % drafts.length; + selectedDraft = drafts[nextIndex]; + _safeNotify(); + return true; + } + + /// Selects the previous draft in the visible list relative to the current + /// [selectedDraft]. Wraps to the last item at the beginning of the list. + /// + /// Returns `true` if the selection changed. + bool selectPreviousDraft() { + if (drafts.isEmpty) return false; + if (selectedDraft == null) { + selectedDraft = drafts.last; + _safeNotify(); + return true; + } + final currentIndex = drafts.indexWhere((d) => d.id == selectedDraft!.id); + final prevIndex = (currentIndex - 1 + drafts.length) % drafts.length; + selectedDraft = drafts[prevIndex]; + _safeNotify(); + return true; + } + /// 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 6ae2290..1d31f07 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 @@ -1,5 +1,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../application/get_product_drafts.dart'; import '../application/product_publishing_controller.dart'; @@ -50,10 +51,13 @@ class ProductPublishingPage extends StatefulWidget { class _ProductPublishingPageState extends State { late final ProductPublishingController controller; + late final FocusNode _listFocusNode; @override void initState() { super.initState(); + _listFocusNode = FocusNode(); + final repo = widget.repository; controller = ProductPublishingController( GetProductDrafts(repo), @@ -87,6 +91,7 @@ class _ProductPublishingPageState extends State { void dispose() { controller.removeListener(_onControllerChanged); controller.dispose(); + _listFocusNode.dispose(); super.dispose(); } @@ -122,6 +127,24 @@ class _ProductPublishingPageState extends State { } } + /// Handles keyboard events for list navigation. + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + controller.selectNextDraft(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + controller.selectPreviousDraft(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -182,6 +205,7 @@ class _ProductPublishingPageState extends State { Widget _buildDraftList() { final count = controller.drafts.length; + final isCompact = controller.listDensity == ListDensity.compact; if (count == 0) { return Center( @@ -201,44 +225,65 @@ 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( - '$count ${count == 1 ? 'product' : 'products'}', - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), - ), - ), - Expanded( - child: ListView.separated( - itemCount: count, - separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm), - itemBuilder: (context, index) { - final draft = controller.drafts[index]; - return SizedBox( - height: 160, - child: ProductDraftCard( - draft: draft, - isSelected: draft.id == controller.selectedDraft?.id, - onTap: () => controller.selectDraft(draft), - isMultiSelected: controller.isMultiSelected(draft.id), - onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id), + return Focus( + focusNode: _listFocusNode, + onKeyEvent: _handleKeyEvent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Multi-select action bar ────────────────────────────────── + if (controller.isMultiSelectActive) + _MultiSelectBar( + selectedCount: controller.multiSelectedCount, + totalCount: count, + onSelectAll: controller.selectAllVisible, + onClearSelection: controller.clearMultiSelection, + ), + // ── List header with count and density toggle ─────────────── + Padding( + padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm), + child: Row( + children: [ + Text( + '$count ${count == 1 ? 'product' : 'products'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), ), - ); - }, + const Spacer(), + Tooltip( + message: isCompact ? 'Standard view' : 'Compact view', + child: IconButton( + icon: Icon(isCompact ? Icons.view_agenda : Icons.view_headline, size: 20), + onPressed: controller.toggleListDensity, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + ], + ), ), - ), - ], + Expanded( + child: ListView.separated( + itemCount: count, + separatorBuilder: (_, _) => SizedBox(height: isCompact ? KcSpacing.xs : KcSpacing.sm), + itemBuilder: (context, index) { + final draft = controller.drafts[index]; + return SizedBox( + height: isCompact ? 72 : 180, + child: ProductDraftCard( + draft: draft, + isSelected: draft.id == controller.selectedDraft?.id, + onTap: () => controller.selectDraft(draft), + isMultiSelected: controller.isMultiSelected(draft.id), + onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id), + isCompact: isCompact, + isStale: controller.isStale(draft), + ), + ); + }, + ), + ), + ], + ), ); } 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 9d88608..6a15f1b 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 @@ -12,6 +12,13 @@ import 'publish_status_chip.dart'; /// 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. +/// +/// When [isCompact] is true, the card uses a denser layout with a smaller +/// height and condensed metadata. A [descriptionSnippet] may be shown in +/// standard mode for lightweight secondary metadata visibility. +/// +/// When [isStale] is true, a warning icon is displayed to flag products +/// that have not been modified recently. class ProductDraftCard extends StatelessWidget { final ProductDraft draft; final bool isSelected; @@ -24,6 +31,12 @@ class ProductDraftCard extends StatelessWidget { /// Called when the multi-select checkbox is toggled. final VoidCallback? onMultiSelectToggle; + /// Whether to use the compact (denser) layout. + final bool isCompact; + + /// Whether this product is stale (not modified recently). + final bool isStale; + const ProductDraftCard({ super.key, required this.draft, @@ -31,6 +44,8 @@ class ProductDraftCard extends StatelessWidget { this.onTap, this.isMultiSelected, this.onMultiSelectToggle, + this.isCompact = false, + this.isStale = false, }); @override @@ -41,14 +56,14 @@ class ProductDraftCard extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.all(KcSpacing.md), + padding: EdgeInsets.all(isCompact ? KcSpacing.sm : KcSpacing.md), decoration: BoxDecoration( color: KcColors.surface, border: Border.all( color: isSelected ? KcColors.denimBlue : KcColors.border, width: isSelected ? 2 : 1, ), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(isCompact ? 10 : 16), boxShadow: const [ BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), ], @@ -64,54 +79,134 @@ class ProductDraftCard extends StatelessWidget { 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), - ), - ], - ), - ], - ), + child: isCompact ? _buildCompactContent(context) : _buildStandardContent(context), ), ], ), ), ); } + + /// Standard layout — full metadata with description snippet and spacer. + Widget _buildStandardContent(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + draft.name, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isStale) ...[ + const SizedBox(width: KcSpacing.xs), + Tooltip( + message: 'Not modified in 30+ days', + child: Icon(Icons.schedule, size: 16, color: KcColors.warning), + ), + ], + ], + ), + const SizedBox(height: KcSpacing.xs), + Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: KcSpacing.xs), + // Description snippet for lightweight secondary metadata visibility. + Text( + draft.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + Row( + children: [ + Text( + '\$${draft.price.toStringAsFixed(2)}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(width: KcSpacing.sm), + Flexible( + child: Text( + draft.category, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: KcSpacing.xs), + 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), + ), + ], + ), + ], + ); + } + + /// Compact layout — single-row-dense metadata for faster scanning. + Widget _buildCompactContent(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (isStale) ...[ + Tooltip( + message: 'Not modified in 30+ days', + child: Icon(Icons.schedule, size: 14, color: KcColors.warning), + ), + const SizedBox(width: KcSpacing.xs), + ], + Expanded( + child: Text( + draft.name, + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: KcSpacing.sm), + PublishStatusChip(status: draft.status), + ], + ), + const SizedBox(height: KcSpacing.xs), + Row( + children: [ + Text(draft.sku, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral)), + const SizedBox(width: KcSpacing.sm), + Text( + '\$${draft.price.toStringAsFixed(2)}', + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(width: KcSpacing.sm), + Flexible( + child: Text( + draft.category, + style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: KcSpacing.sm), + Text( + '${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}', + style: theme.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 6d38ef8..1aa8b1b 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 @@ -1214,6 +1214,236 @@ void main() { }); }); + // ── List density ──────────────────────────────────────────────────── + + group('listDensity', () { + test('defaults to standard', () { + expect(controller.listDensity, ListDensity.standard); + }); + + test('toggleListDensity switches to compact', () { + controller.toggleListDensity(); + expect(controller.listDensity, ListDensity.compact); + }); + + test('toggleListDensity switches back to standard', () { + controller.toggleListDensity(); + expect(controller.listDensity, ListDensity.compact); + + controller.toggleListDensity(); + expect(controller.listDensity, ListDensity.standard); + }); + + test('toggleListDensity notifies listeners', () { + int notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.toggleListDensity(); + expect(notifyCount, 1); + }); + + test('listDensity persists across load cycles', () async { + controller.toggleListDensity(); + expect(controller.listDensity, ListDensity.compact); + + await controller.load(); + + expect(controller.listDensity, ListDensity.compact); + }); + }); + + // ── Staleness detection ───────────────────────────────────────────── + + group('staleness', () { + test('product modified today is not stale', () async { + await controller.load(); + + final fresh = controller.drafts.first; + // Use a reference time very close to the fake data dates. + final now = DateTime(2026, 4, 3); + expect(controller.isStale(fresh, now: now), isFalse); + }); + + test('product modified 30+ days ago is stale', () async { + await controller.load(); + + // Product 6 has lastModified = 2026-03-20. + final old = controller.drafts.firstWhere((d) => d.id == '6'); + final now = DateTime(2026, 4, 20); // 31 days later + expect(controller.isStale(old, now: now), isTrue); + }); + + test('product modified exactly 30 days ago is stale', () async { + await controller.load(); + + // Product 6 has lastModified = 2026-03-20. + final old = controller.drafts.firstWhere((d) => d.id == '6'); + final now = DateTime(2026, 4, 19); // exactly 30 days + expect(controller.isStale(old, now: now), isTrue); + }); + + test('product modified 29 days ago is not stale', () async { + await controller.load(); + + // Product 6 has lastModified = 2026-03-20. + final old = controller.drafts.firstWhere((d) => d.id == '6'); + final now = DateTime(2026, 4, 18); // 29 days + expect(controller.isStale(old, now: now), isFalse); + }); + + test('staleCount returns correct count', () async { + await controller.load(); + + // With now = 2026-05-01, all 6 products have lastModified in March/April. + // All are older than 30 days from May 1. + // Dates: Mar20, Mar25, Mar28, Apr1, Apr2, Apr3 + // Days from May 1: 42, 37, 34, 30, 29, 28 + // Stale (>=30): Mar20(42), Mar25(37), Mar28(34), Apr1(30) = 4 + final now = DateTime(2026, 5, 1); + expect(controller.staleCount(now: now), 4); + }); + + test('staleCount respects active filter', () async { + await controller.load(); + + // Filter to drafts only (ids 4 and 5, dates Apr2 and Apr3). + controller.setFilter('draft'); + + // With now = 2026-05-03: Apr2 = 31 days (stale), Apr3 = 30 days (stale) + final now = DateTime(2026, 5, 3); + expect(controller.staleCount(now: now), 2); + }); + + test('freshly written product is no longer stale', () async { + await controller.load(); + + // Product 6 (Mar20) is stale as of May 1. + final now = DateTime(2026, 5, 1); + final oldProduct = controller.drafts.firstWhere((d) => d.id == '6'); + expect(controller.isStale(oldProduct, now: now), isTrue); + + // Update its price — the fake repo sets lastModified to DateTime.now(). + await controller.updatePrice('6', 20.00); + + // After the write, the product has a fresh lastModified. + final updated = controller.drafts.firstWhere((d) => d.id == '6'); + expect(controller.isStale(updated), isFalse); + }); + }); + + // ── Keyboard navigation ───────────────────────────────────────────── + + group('keyboard navigation', () { + test('selectNextDraft moves to next item', () async { + await controller.load(); + + // Default sort: name ascending. First selected = Citrus Coaster Set (id 2). + expect(controller.selectedDraft!.id, '2'); + + controller.selectNextDraft(); + // Next: Fabric Jar Gripper (id 4). + expect(controller.selectedDraft!.id, '4'); + }); + + test('selectNextDraft wraps to first at end of list', () async { + await controller.load(); + + // Select last item: Sublimated Slate Coaster (id 6). + controller.selectDraft(controller.drafts.last); + expect(controller.selectedDraft!.id, '6'); + + controller.selectNextDraft(); + // Should wrap to first: Citrus Coaster Set (id 2). + expect(controller.selectedDraft!.id, '2'); + }); + + test('selectPreviousDraft moves to previous item', () async { + await controller.load(); + + // Select 2nd item: Fabric Jar Gripper (id 4). + controller.selectDraft(controller.drafts[1]); + expect(controller.selectedDraft!.id, '4'); + + controller.selectPreviousDraft(); + // Previous: Citrus Coaster Set (id 2). + expect(controller.selectedDraft!.id, '2'); + }); + + test('selectPreviousDraft wraps to last at beginning of list', () async { + await controller.load(); + + // Already at first item: Citrus Coaster Set (id 2). + expect(controller.selectedDraft!.id, '2'); + + controller.selectPreviousDraft(); + // Should wrap to last: Sublimated Slate Coaster (id 6). + expect(controller.selectedDraft!.id, '6'); + }); + + test('selectNextDraft selects first when no selection', () async { + await controller.load(); + controller.selectedDraft = null; + + final result = controller.selectNextDraft(); + expect(result, isTrue); + expect(controller.selectedDraft!.id, '2'); // First in list. + }); + + test('selectPreviousDraft selects last when no selection', () async { + await controller.load(); + controller.selectedDraft = null; + + final result = controller.selectPreviousDraft(); + expect(result, isTrue); + expect(controller.selectedDraft!.id, '6'); // Last in list. + }); + + test('selectNextDraft returns false on empty list', () async { + await controller.load(); + controller.setSearchQuery('zzz_no_match'); + + final result = controller.selectNextDraft(); + expect(result, isFalse); + }); + + test('selectPreviousDraft returns false on empty list', () async { + await controller.load(); + controller.setSearchQuery('zzz_no_match'); + + final result = controller.selectPreviousDraft(); + expect(result, isFalse); + }); + + test('selectNextDraft notifies listeners', () async { + await controller.load(); + + int notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.selectNextDraft(); + expect(notifyCount, 1); + }); + + test('keyboard navigation respects active filter', () async { + await controller.load(); + + // Filter to drafts only (ids 4 and 5, name-sorted: Fabric, Skillet). + controller.setFilter('draft'); + expect(controller.drafts.length, 2); + + // Auto-selection cleared or set to first visible. + controller.selectDraft(controller.drafts.first); + expect(controller.selectedDraft!.id, '4'); + + controller.selectNextDraft(); + expect(controller.selectedDraft!.id, '5'); + + controller.selectNextDraft(); + // Wraps back to first. + expect(controller.selectedDraft!.id, '4'); + }); + }); + group('disposed guard', () { test('load does not notify after disposal', () async { // Start load, then immediately dispose. diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart index 758cb77..600b684 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart @@ -18,17 +18,25 @@ void main() { lastModified: DateTime(2026, 4, 1), ); - Widget buildTestWidget({ProductDraft? draft, bool isSelected = false, VoidCallback? onTap}) { + Widget buildTestWidget({ + ProductDraft? draft, + bool isSelected = false, + VoidCallback? onTap, + bool isCompact = false, + bool isStale = false, + }) { return MaterialApp( theme: buildKcTheme(), home: Scaffold( body: SizedBox( - height: 200, - width: 400, + height: isCompact ? 72 : 200, + width: isCompact ? 600 : 400, child: ProductDraftCard( draft: draft ?? sampleDraft, isSelected: isSelected, onTap: onTap, + isCompact: isCompact, + isStale: isStale, ), ), ), @@ -78,5 +86,62 @@ void main() { await tester.tap(find.text('Test Bowl Cozy')); expect(tapped, true); }); + + // ── Description snippet (standard mode) ──────────────────────────── + + testWidgets('standard mode shows description snippet', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('A test product'), findsOneWidget); + }); + + // ── Stale indicator ───────────────────────────────────────────────── + + testWidgets('shows stale indicator when isStale is true', (tester) async { + await tester.pumpWidget(buildTestWidget(isStale: true)); + expect(find.byIcon(Icons.schedule), findsOneWidget); + }); + + testWidgets('hides stale indicator when isStale is false', (tester) async { + await tester.pumpWidget(buildTestWidget(isStale: false)); + expect(find.byIcon(Icons.schedule), findsNothing); + }); + + // ── Compact mode ───────────────────────────────────────────────────── + + testWidgets('compact mode displays product name', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true)); + expect(find.text('Test Bowl Cozy'), findsOneWidget); + }); + + testWidgets('compact mode displays SKU without prefix', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true)); + // In compact mode, the SKU is shown without the "SKU: " prefix. + expect(find.text('BC-TST-001'), findsOneWidget); + }); + + testWidgets('compact mode displays price', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true)); + expect(find.text('\$12.99'), findsOneWidget); + }); + + testWidgets('compact mode displays status chip', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true)); + expect(find.text('Draft'), findsOneWidget); + }); + + testWidgets('compact mode displays last modified date', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true)); + expect(find.text('2026-04-01'), findsOneWidget); + }); + + testWidgets('compact mode shows stale indicator when isStale is true', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true, isStale: true)); + expect(find.byIcon(Icons.schedule), findsOneWidget); + }); + + testWidgets('compact mode hides stale indicator when isStale is false', (tester) async { + await tester.pumpWidget(buildTestWidget(isCompact: true, isStale: false)); + expect(find.byIcon(Icons.schedule), findsNothing); + }); }); }