Compare commits

..

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

7 changed files with 105 additions and 607 deletions

View File

@ -82,7 +82,6 @@ Rules:
- ✅ 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).
- ✅ List efficiency improvements landed (Stage 3B complete — merged `feat/list-efficiency-improvements``main`, 2026-05-22). Stage 3 complete.
### Current narrow edit capabilities on `main`
@ -97,14 +96,14 @@ Rules:
- `dart analyze` clean
- `feature_wordpress` tests passing
- `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `294/294 passed`
- latest reported count for `feature_wordpress`: `262/262 passed`
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline commit: merge of `feat/list-efficiency-improvements` (2026-05-22)
- baseline commit: merge of `feat/multi-select-groundwork` (2026-05-22)
### Next recommended branch
**`feat/android-app-shell`** — Stage 4A: Android app shell and bootstrap.
Branch from latest `main`. Stage 3 (web application operator efficiency) is complete.
**`feat/list-efficiency-improvements`** — Stage 3B: List efficiency improvements.
Branch from latest `main`. Stage 3A (multi-select groundwork) is complete.
---
@ -191,10 +190,22 @@ Increase throughput for product triage and management using existing data.
> Merged `feat/multi-select-groundwork``main` (2026-05-22).
> Added multi-selection state to `ProductPublishingController` (`toggleMultiSelect`, `clearMultiSelection`, `selectAllVisible`, `isMultiSelected`, `multiSelectedIds`, `multiSelectedCount`). Updated `ProductDraftCard` with optional leading checkbox for multi-select mode. Added `_MultiSelectBar` to `ProductPublishingPage` with selected-count display, Select All, and Clear actions. Single-item preview selection preserved independently. 15 new multi-select tests added (262 total `feature_wordpress` tests passing). No bulk writes introduced.
#### ~~Stage 3B — List efficiency improvements~~ ✅ COMPLETE
#### Stage 3B — List efficiency improvements
> Merged `feat/list-efficiency-improvements``main` (2026-05-22).
> Added compact/standard view toggle (`ListDensity` enum), staleness detection (`isStale()`, `staleCount()` with 30-day threshold), keyboard navigation (`selectNextDraft`/`selectPreviousDraft` with wrapping), description snippet in standard card, and stale indicator icon. Updated `ProductDraftCard` with compact two-row layout variant and `Flexible` overflow handling. Page wires up `Focus`+`onKeyEvent` for arrow keys and density toggle button. 32 new tests added (294 total `feature_wordpress` tests passing). Stage 3 complete.
##### Goal
Further improve operator productivity.
##### Candidate scope
- denser list/card presentation if justified
- quick visual indicators for stale products
- lightweight secondary metadata visibility
- improved keyboard/focus handling on web if easy to support
##### Definition of done
- measurable usability improvement using existing data only
---

View File

@ -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 ListDensity, ProductSortField;
export 'src/application/product_publishing_controller.dart' show ProductSortField;
export 'src/application/update_product_category.dart';
export 'src/application/update_product_description.dart';
export 'src/application/update_product_name.dart';

View File

@ -10,15 +10,6 @@ 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.
@ -166,15 +157,6 @@ 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.
@ -215,66 +197,6 @@ 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

View File

@ -1,6 +1,5 @@
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';
@ -51,13 +50,10 @@ class ProductPublishingPage extends StatefulWidget {
class _ProductPublishingPageState extends State<ProductPublishingPage> {
late final ProductPublishingController controller;
late final FocusNode _listFocusNode;
@override
void initState() {
super.initState();
_listFocusNode = FocusNode();
final repo = widget.repository;
controller = ProductPublishingController(
GetProductDrafts(repo),
@ -91,7 +87,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
void dispose() {
controller.removeListener(_onControllerChanged);
controller.dispose();
_listFocusNode.dispose();
super.dispose();
}
@ -127,24 +122,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
}
}
/// 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(
@ -205,7 +182,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
Widget _buildDraftList() {
final count = controller.drafts.length;
final isCompact = controller.listDensity == ListDensity.compact;
if (count == 0) {
return Center(
@ -225,10 +201,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
);
}
return Focus(
focusNode: _listFocusNode,
onKeyEvent: _handleKeyEvent,
child: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Multi-select action bar
@ -239,51 +212,33 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
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(
child: 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),
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
itemBuilder: (context, index) {
final draft = controller.drafts[index];
return SizedBox(
height: isCompact ? 72 : 180,
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),
isCompact: isCompact,
isStale: controller.isStale(draft),
),
);
},
),
),
],
),
);
}

View File

@ -12,13 +12,6 @@ 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;
@ -31,12 +24,6 @@ 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,
@ -44,8 +31,6 @@ class ProductDraftCard extends StatelessWidget {
this.onTap,
this.isMultiSelected,
this.onMultiSelectToggle,
this.isCompact = false,
this.isStale = false,
});
@override
@ -56,14 +41,14 @@ class ProductDraftCard extends StatelessWidget {
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.all(isCompact ? KcSpacing.sm : KcSpacing.md),
padding: const EdgeInsets.all(KcSpacing.md),
decoration: BoxDecoration(
color: KcColors.surface,
border: Border.all(
color: isSelected ? KcColors.denimBlue : KcColors.border,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(isCompact ? 10 : 16),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
],
@ -79,134 +64,54 @@ class ProductDraftCard extends StatelessWidget {
const SizedBox(width: KcSpacing.xs),
],
Expanded(
child: isCompact ? _buildCompactContent(context) : _buildStandardContent(context),
),
],
),
),
);
}
/// Standard layout full metadata with description snippet and spacer.
Widget _buildStandardContent(BuildContext context) {
return Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
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(),
const SizedBox(height: KcSpacing.sm),
Row(
children: [
Text(
'\$${draft.price.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(width: KcSpacing.sm),
Flexible(
child: Text(
Text(
draft.category,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
overflow: TextOverflow.ellipsis,
),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
),
],
),
const SizedBox(height: KcSpacing.xs),
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),
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),
),
],
),
],
),
);
}
}

View File

@ -1214,236 +1214,6 @@ 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.

View File

@ -18,25 +18,17 @@ void main() {
lastModified: DateTime(2026, 4, 1),
);
Widget buildTestWidget({
ProductDraft? draft,
bool isSelected = false,
VoidCallback? onTap,
bool isCompact = false,
bool isStale = false,
}) {
Widget buildTestWidget({ProductDraft? draft, bool isSelected = false, VoidCallback? onTap}) {
return MaterialApp(
theme: buildKcTheme(),
home: Scaffold(
body: SizedBox(
height: isCompact ? 72 : 200,
width: isCompact ? 600 : 400,
height: 200,
width: 400,
child: ProductDraftCard(
draft: draft ?? sampleDraft,
isSelected: isSelected,
onTap: onTap,
isCompact: isCompact,
isStale: isStale,
),
),
),
@ -86,62 +78,5 @@ 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);
});
});
}