Merge feat/list-efficiency-improvements into main — Stage 3B complete
This commit is contained in:
commit
738336d953
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ProductPublishingPage> {
|
||||
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<ProductPublishingPage> {
|
|||
void dispose() {
|
||||
controller.removeListener(_onControllerChanged);
|
||||
controller.dispose();
|
||||
_listFocusNode.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
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
|
@ -182,6 +205,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
|
||||
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<ProductPublishingPage> {
|
|||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Multi-select action bar ──────────────────────────────────
|
||||
if (controller.isMultiSelectActive)
|
||||
_MultiSelectBar(
|
||||
selectedCount: controller.multiSelectedCount,
|
||||
totalCount: count,
|
||||
onSelectAll: controller.selectAllVisible,
|
||||
onClearSelection: controller.clearMultiSelection,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||
child: Text(
|
||||
'$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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue