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); + }); }); }