Compare commits

..

3 Commits

Author SHA1 Message Date
Mike Kell eaf3e70d30 docs: update master development brief — Stage 3B complete, Stage 3 done
Publish Docs / publish-docs (push) Successful in 1m10s Details
Mark Stage 3B (list efficiency improvements) as complete with 294 tests passing. Update baseline commit reference, test count, and next recommended branch to Stage 4A (Android app shell).
2026-05-22 08:53:10 -04:00
Mike Kell 738336d953 Merge feat/list-efficiency-improvements into main — Stage 3B complete 2026-05-22 08:51:57 -04:00
Mike Kell a0aea373c2 feat(wordpress): Stage 3B — list efficiency improvements
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.
2026-05-22 08:51:32 -04:00
7 changed files with 607 additions and 105 deletions

View File

@ -82,6 +82,7 @@ Rules:
- ✅ Post-write consistency hardening landed (Stage 2A complete — merged `feat/post-write-consistency``main` at `7acff83`, 2026-04-11). - ✅ Post-write consistency hardening landed (Stage 2A complete — merged `feat/post-write-consistency``main` at `7acff83`, 2026-04-11).
- ✅ Publishing workflow UX hardening landed (Stage 2B complete — merged `feat/publishing-ux-hardening``main` at `b81016d`, 2026-04-11). Stage 2 complete. - ✅ Publishing workflow UX hardening landed (Stage 2B complete — merged `feat/publishing-ux-hardening``main` at `b81016d`, 2026-04-11). Stage 2 complete.
- ✅ Multi-select groundwork landed (Stage 3A complete — merged `feat/multi-select-groundwork``main`, 2026-05-22). - ✅ 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` ### Current narrow edit capabilities on `main`
@ -96,14 +97,14 @@ Rules:
- `dart analyze` clean - `dart analyze` clean
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `kell_web` dashboard tests passing - `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `262/262 passed` - latest reported count for `feature_wordpress`: `294/294 passed`
- latest reported count for `kell_web` dashboard tests: `5/5 passed` - latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline commit: merge of `feat/multi-select-groundwork` (2026-05-22) - baseline commit: merge of `feat/list-efficiency-improvements` (2026-05-22)
### Next recommended branch ### Next recommended branch
**`feat/list-efficiency-improvements`** — Stage 3B: List efficiency improvements. **`feat/android-app-shell`** — Stage 4A: Android app shell and bootstrap.
Branch from latest `main`. Stage 3A (multi-select groundwork) is complete. Branch from latest `main`. Stage 3 (web application operator efficiency) is complete.
--- ---
@ -190,22 +191,10 @@ Increase throughput for product triage and management using existing data.
> Merged `feat/multi-select-groundwork``main` (2026-05-22). > 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. > 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 #### ~~Stage 3B — List efficiency improvements~~ ✅ COMPLETE
##### Goal > 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.
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'; export 'src/domain/publish_status.dart';
// Application // 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_category.dart';
export 'src/application/update_product_description.dart'; export 'src/application/update_product_description.dart';
export 'src/application/update_product_name.dart'; export 'src/application/update_product_name.dart';

View File

@ -10,6 +10,15 @@ import 'update_product_name.dart';
import 'update_product_price.dart'; import 'update_product_price.dart';
import 'update_product_status.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. /// The field used to sort the visible product list.
enum ProductSortField { enum ProductSortField {
/// Sort alphabetically by product name. /// Sort alphabetically by product name.
@ -157,6 +166,15 @@ class ProductPublishingController extends ChangeNotifier {
/// Whether the current sort is ascending (`true`) or descending (`false`). /// Whether the current sort is ascending (`true`) or descending (`false`).
bool sortAscending = true; bool sortAscending = true;
/// 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. /// Product IDs that the operator has selected for potential bulk actions.
/// ///
/// Multi-select is independent of the single-item [selectedDraft] preview. /// Multi-select is independent of the single-item [selectedDraft] preview.
@ -197,6 +215,66 @@ class ProductPublishingController extends ChangeNotifier {
_safeNotify(); _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. /// Product IDs that currently have an in-flight status update.
/// ///
/// Used to prevent duplicate clicks and to let the UI show per-row /// Used to prevent duplicate clicks and to let the UI show per-row

View File

@ -1,5 +1,6 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../application/get_product_drafts.dart'; import '../application/get_product_drafts.dart';
import '../application/product_publishing_controller.dart'; import '../application/product_publishing_controller.dart';
@ -50,10 +51,13 @@ class ProductPublishingPage extends StatefulWidget {
class _ProductPublishingPageState extends State<ProductPublishingPage> { class _ProductPublishingPageState extends State<ProductPublishingPage> {
late final ProductPublishingController controller; late final ProductPublishingController controller;
late final FocusNode _listFocusNode;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_listFocusNode = FocusNode();
final repo = widget.repository; final repo = widget.repository;
controller = ProductPublishingController( controller = ProductPublishingController(
GetProductDrafts(repo), GetProductDrafts(repo),
@ -87,6 +91,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
void dispose() { void dispose() {
controller.removeListener(_onControllerChanged); controller.removeListener(_onControllerChanged);
controller.dispose(); controller.dispose();
_listFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -122,6 +127,24 @@ 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
@ -182,6 +205,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
Widget _buildDraftList() { Widget _buildDraftList() {
final count = controller.drafts.length; final count = controller.drafts.length;
final isCompact = controller.listDensity == ListDensity.compact;
if (count == 0) { if (count == 0) {
return Center( return Center(
@ -201,44 +225,65 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
); );
} }
return Column( return Focus(
crossAxisAlignment: CrossAxisAlignment.start, focusNode: _listFocusNode,
children: [ onKeyEvent: _handleKeyEvent,
// Multi-select action bar child: Column(
if (controller.isMultiSelectActive) crossAxisAlignment: CrossAxisAlignment.start,
_MultiSelectBar( children: [
selectedCount: controller.multiSelectedCount, // Multi-select action bar
totalCount: count, if (controller.isMultiSelectActive)
onSelectAll: controller.selectAllVisible, _MultiSelectBar(
onClearSelection: controller.clearMultiSelection, selectedCount: controller.multiSelectedCount,
), totalCount: count,
Padding( onSelectAll: controller.selectAllVisible,
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm), onClearSelection: controller.clearMultiSelection,
child: Text( ),
'$count ${count == 1 ? 'product' : 'products'}', // List header with count and density toggle
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), Padding(
), padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
), child: Row(
Expanded( children: [
child: ListView.separated( Text(
itemCount: count, '$count ${count == 1 ? 'product' : 'products'}',
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
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),
), ),
); 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),
),
);
},
),
),
],
),
); );
} }

View File

@ -12,6 +12,13 @@ import 'publish_status_chip.dart';
/// When [isMultiSelected] is non-null, a leading checkbox is shown to /// When [isMultiSelected] is non-null, a leading checkbox is shown to
/// support multi-select mode. Tapping the checkbox fires [onMultiSelectToggle]; /// support multi-select mode. Tapping the checkbox fires [onMultiSelectToggle];
/// tapping the rest of the card still fires [onTap] for single-item preview. /// 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 { class ProductDraftCard extends StatelessWidget {
final ProductDraft draft; final ProductDraft draft;
final bool isSelected; final bool isSelected;
@ -24,6 +31,12 @@ class ProductDraftCard extends StatelessWidget {
/// Called when the multi-select checkbox is toggled. /// Called when the multi-select checkbox is toggled.
final VoidCallback? onMultiSelectToggle; 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({ const ProductDraftCard({
super.key, super.key,
required this.draft, required this.draft,
@ -31,6 +44,8 @@ class ProductDraftCard extends StatelessWidget {
this.onTap, this.onTap,
this.isMultiSelected, this.isMultiSelected,
this.onMultiSelectToggle, this.onMultiSelectToggle,
this.isCompact = false,
this.isStale = false,
}); });
@override @override
@ -41,14 +56,14 @@ class ProductDraftCard extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(KcSpacing.md), padding: EdgeInsets.all(isCompact ? KcSpacing.sm : KcSpacing.md),
decoration: BoxDecoration( decoration: BoxDecoration(
color: KcColors.surface, color: KcColors.surface,
border: Border.all( border: Border.all(
color: isSelected ? KcColors.denimBlue : KcColors.border, color: isSelected ? KcColors.denimBlue : KcColors.border,
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(isCompact ? 10 : 16),
boxShadow: const [ boxShadow: const [
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
], ],
@ -64,54 +79,134 @@ class ProductDraftCard extends StatelessWidget {
const SizedBox(width: KcSpacing.xs), const SizedBox(width: KcSpacing.xs),
], ],
Expanded( Expanded(
child: Column( child: isCompact ? _buildCompactContent(context) : _buildStandardContent(context),
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),
),
],
),
],
),
), ),
], ],
), ),
), ),
); );
} }
/// 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),
),
],
),
],
);
}
} }

View File

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

View File

@ -18,17 +18,25 @@ void main() {
lastModified: DateTime(2026, 4, 1), 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( return MaterialApp(
theme: buildKcTheme(), theme: buildKcTheme(),
home: Scaffold( home: Scaffold(
body: SizedBox( body: SizedBox(
height: 200, height: isCompact ? 72 : 200,
width: 400, width: isCompact ? 600 : 400,
child: ProductDraftCard( child: ProductDraftCard(
draft: draft ?? sampleDraft, draft: draft ?? sampleDraft,
isSelected: isSelected, isSelected: isSelected,
onTap: onTap, onTap: onTap,
isCompact: isCompact,
isStale: isStale,
), ),
), ),
), ),
@ -78,5 +86,62 @@ void main() {
await tester.tap(find.text('Test Bowl Cozy')); await tester.tap(find.text('Test Bowl Cozy'));
expect(tapped, true); 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);
});
}); });
} }