Compare commits

..

No commits in common. "73fdcf99e78fd6966deddc5b8d0f7895a7819da2" and "b2df206123cd0033f6883cdc5f892336e2a2c552" have entirely different histories.

9 changed files with 45 additions and 968 deletions

View File

@ -2,9 +2,9 @@
## Current status ## Current status
- main baseline updated through: feat/inventory-page-ui (Stage 5B complete) - main baseline updated through: feat/inventory-domain-expansion (Stage 5A complete)
- next branch: feat/orders-domain (Stage 5C) or feat/bulk-operator-workflows (Stage 7B) - next branch: feat/inventory-page-ui (Stage 5B) or feat/orders-domain (Stage 5C)
- current stage: Stage 5B complete — inventory page UI with search, filter chips, detail panel, quantity adjust dialog, and snack bar feedback - current stage: Stage 5A complete — inventory domain expansion with write actions
## Slice tracker ## Slice tracker
@ -266,25 +266,6 @@
- analyze: passed (dart analyze — no issues found) - analyze: passed (dart analyze — no issues found)
- brief updated: yes - brief updated: yes
### feat/inventory-page-ui
- status: merged to main
- date: 2026-07-10
- inspection: complete
- implementation: complete
- files changed:
- `feature_inventory/lib/src/presentation/inventory_page.dart` — full inventory page with search bar, status filter chips (All / In Stock / Low Stock / Out of Stock / Draft), item count display, `InventoryItemCard` list, `initialFilter` and `initialQuery` constructor params
- `feature_inventory/lib/src/presentation/widgets/inventory_status_chip.dart``InventoryStatusChip` widget mapping `InventoryStatus` to label and color
- `feature_inventory/lib/src/presentation/widgets/inventory_detail_panel.dart``InventoryDetailPanel` with name, SKU, description, quantity, price, status chip, supplier, location, reorder point, last updated; Adjust Quantity and View Product action buttons; `isUpdating` loading indicator
- `feature_inventory/lib/src/presentation/widgets/adjust_quantity_dialog.dart``AdjustQuantityDialog` with +/- delta input, reason field, and `AdjustQuantityDialogResult` value object
- `feature_inventory/lib/src/presentation/inventory_action_snack_bar.dart``showInventoryActionSnackBar()` helper for success/failure feedback
- `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for all new presentation types
- `feature_inventory/test/widgets/inventory_page_test.dart` — 26 new widget tests covering `InventoryStatusChip` (4), `InventoryDetailPanel` (11), `InventoryPage` (9), `AdjustQuantityDialogResult` (2)
- `kell_creations_apps/tools/run_all_tests.sh` — added `feature_inventory` to `TESTABLE` list
- tests: passed (75/75 feature_inventory, 311/311 feature_wordpress, 24/24 kell_web, 14/14 kell_mobile, 41/41 design_system, 20/20 core — 485 total)
- analyze: passed (dart analyze --fatal-infos — no issues found)
- brief updated: yes
### chore/analyze-cleanup-and-tracker-sync ### chore/analyze-cleanup-and-tracker-sync
- status: merged to main - status: merged to main

View File

@ -125,18 +125,16 @@ Rules:
- `dart analyze` clean - `dart analyze` clean
- `core` tests passing - `core` tests passing
- `design_system` tests passing - `design_system` tests passing
- `feature_inventory` tests passing
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `kell_web` tests passing - `kell_web` tests passing
- `kell_mobile` tests passing - `kell_mobile` tests passing
- latest reported count for `core`: `20/20 passed` - latest reported count for `core`: `20/20 passed`
- latest reported count for `design_system`: `41/41 passed` - latest reported count for `design_system`: `41/41 passed`
- latest reported count for `feature_inventory`: `75/75 passed`
- latest reported count for `feature_wordpress`: `311/311 passed` - latest reported count for `feature_wordpress`: `311/311 passed`
- latest reported count for `kell_web`: `24/24 passed` - latest reported count for `kell_web`: `24/24 passed`
- latest reported count for `kell_mobile`: `14/14 passed` - latest reported count for `kell_mobile`: `14/14 passed`
- total: `485/485 passed` - total: `410/410 passed`
- baseline commit: merge of `feat/inventory-page-ui` (Stage 5B complete, 2026-07-10) - baseline commit: merge of `feat/bulk-status-actions` + analyze cleanup (2026-05-30)
#### Baseline test coverage (established 2026-05-22) #### Baseline test coverage (established 2026-05-22)
@ -152,8 +150,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch ### Next recommended branch
**`feat/orders-domain`** — Stage 5C: Orders domain expansion (read + write operations, matching the pattern established by `feat/inventory-domain-expansion`), or **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review). **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review), or **`feat/integrations-contracts`** — Stage 8A: Integration abstractions.
Branch from latest `main`. Stage 5B (inventory page UI) is complete. Stage 5C adds write operations and domain expansion to the orders feature. Stage 7B adds incremental bulk actions to the publishing workflow. Branch from latest `main`. Stage 7A (bulk move-to-draft) is complete. Stage 7B adds incremental bulk actions. Stage 8A begins infrastructure activation required for all subsequent feature work (Stages 916).
--- ---

View File

@ -10,9 +10,4 @@ export 'src/domain/inventory_adjustment_result.dart';
export 'src/domain/inventory_item.dart'; export 'src/domain/inventory_item.dart';
export 'src/domain/inventory_repository.dart'; export 'src/domain/inventory_repository.dart';
export 'src/domain/inventory_status.dart'; export 'src/domain/inventory_status.dart';
export 'src/presentation/inventory_action_snack_bar.dart';
export 'src/presentation/inventory_page.dart'; export 'src/presentation/inventory_page.dart';
export 'src/presentation/widgets/adjust_quantity_dialog.dart';
export 'src/presentation/widgets/inventory_detail_panel.dart';
export 'src/presentation/widgets/inventory_item_card.dart';
export 'src/presentation/widgets/inventory_status_chip.dart';

View File

@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
import '../domain/inventory_adjustment_result.dart';
/// Shows a [SnackBar] reflecting the outcome of an inventory write action.
///
/// Displays a success message when [result.success] is true, or an error
/// message (with optional detail) when it is false.
void showInventoryActionSnackBar(
BuildContext context,
InventoryAdjustmentResult result, {
String successMessage = 'Quantity updated.',
}) {
if (!context.mounted) return;
final isSuccess = result.success;
final message = isSuccess
? successMessage
: 'Update failed.${result.errorMessage != null ? ' ${result.errorMessage}' : ''}';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isSuccess ? Colors.green.shade700 : Colors.red.shade700,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
}

View File

@ -6,18 +6,9 @@ import '../application/create_inventory_item.dart';
import '../application/get_inventory_items.dart'; import '../application/get_inventory_items.dart';
import '../application/inventory_controller.dart'; import '../application/inventory_controller.dart';
import '../application/update_inventory_item.dart'; import '../application/update_inventory_item.dart';
import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart'; import '../domain/inventory_repository.dart';
import 'inventory_action_snack_bar.dart';
import 'widgets/adjust_quantity_dialog.dart';
import 'widgets/inventory_detail_panel.dart';
import 'widgets/inventory_item_card.dart'; import 'widgets/inventory_item_card.dart';
/// The main inventory workspace page.
///
/// Provides a search bar, status filter chips, a scrollable item list, and a
/// detail panel that appears when an item is selected. Quantity adjustments are
/// performed via a modal dialog and confirmed with a SnackBar.
class InventoryPage extends StatefulWidget { class InventoryPage extends StatefulWidget {
final InventoryRepository repository; final InventoryRepository repository;
@ -48,106 +39,81 @@ class InventoryPage extends StatefulWidget {
} }
class _InventoryPageState extends State<InventoryPage> { class _InventoryPageState extends State<InventoryPage> {
late final InventoryController _controller; late final InventoryController controller;
late final TextEditingController _searchController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = InventoryController( controller = InventoryController(
GetInventoryItems(widget.repository), GetInventoryItems(widget.repository),
AdjustInventoryQuantity(widget.repository), AdjustInventoryQuantity(widget.repository),
CreateInventoryItem(widget.repository), CreateInventoryItem(widget.repository),
UpdateInventoryItem(widget.repository), UpdateInventoryItem(widget.repository),
); );
_searchController = TextEditingController(text: widget.initialQuery ?? '');
// Apply any initial filter / query before loading. // Apply any initial filter / query before loading.
if (widget.initialFilter != null) { if (widget.initialFilter != null) {
_controller.activeFilter = widget.initialFilter; controller.activeFilter = widget.initialFilter;
} }
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
_controller.searchQuery = widget.initialQuery!; controller.searchQuery = widget.initialQuery!;
} }
_controller.addListener(_onControllerChanged); controller.load().then((_) {
// After data is loaded, try to pre-select by SKU if requested.
_controller.load().then((_) {
if (widget.initialSelectedSku != null) { if (widget.initialSelectedSku != null) {
_controller.selectBySku(widget.initialSelectedSku!); controller.selectBySku(widget.initialSelectedSku!);
} }
}); });
} }
@override @override
void dispose() { void dispose() {
_controller.removeListener(_onControllerChanged); controller.dispose();
_controller.dispose();
_searchController.dispose();
super.dispose(); super.dispose();
} }
void _onControllerChanged() {
final result = _controller.lastActionResult;
if (result != null && !_controller.isUpdating) {
showInventoryActionSnackBar(
context,
result,
successMessage: result.success ? 'Quantity updated.' : 'Update failed.',
);
// Consume the result so it is not shown again on the next rebuild.
_controller.lastActionResult = null;
}
}
Future<void> _handleAdjustQuantity(InventoryItem item) async {
final dialogResult = await showAdjustQuantityDialog(context, item);
if (dialogResult == null) return;
await _controller.adjustQuantity(item.id, dialogResult.delta, dialogResult.reason);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: controller,
builder: (context, _) { builder: (context, _) {
if (_controller.isLoading) { if (controller.isLoading) {
return const KcLoadingState(message: 'Loading inventory…'); return const Center(child: CircularProgressIndicator());
} }
if (_controller.error != null) { if (controller.error != null) {
return KcErrorState(message: 'Failed to load inventory data.', onRetry: _controller.load); return const Center(child: Text('Failed to load inventory data.'));
} }
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth >= KcBreakpoints.medium; final width = constraints.maxWidth;
return Column( int crossAxisCount = 1;
children: [ if (width >= 1200) {
_InventoryToolbar( crossAxisCount = 3;
searchController: _searchController, } else if (width >= 700) {
activeFilter: _controller.activeFilter, crossAxisCount = 2;
itemCount: _controller.items.length, }
onSearchChanged: (q) {
_controller.setSearchQuery(q); return GridView.builder(
}, itemCount: controller.items.length,
onFilterChanged: _controller.setFilter, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
), crossAxisCount: crossAxisCount,
Expanded( crossAxisSpacing: KcSpacing.md,
child: isWide mainAxisSpacing: KcSpacing.md,
? _WideLayout( childAspectRatio: 1.5,
controller: _controller, ),
onAdjustQuantity: _handleAdjustQuantity, itemBuilder: (context, index) {
onViewProduct: widget.onViewProduct, final item = controller.items[index];
) return InventoryItemCard(
: _NarrowLayout( item: item,
controller: _controller, onViewProduct: widget.onViewProduct != null
onAdjustQuantity: _handleAdjustQuantity, ? () => widget.onViewProduct!(item.sku)
onViewProduct: widget.onViewProduct, : null,
), );
), },
],
); );
}, },
); );
@ -155,261 +121,3 @@ class _InventoryPageState extends State<InventoryPage> {
); );
} }
} }
// Toolbar
class _InventoryToolbar extends StatelessWidget {
final TextEditingController searchController;
final String? activeFilter;
final int itemCount;
final ValueChanged<String> onSearchChanged;
final ValueChanged<String?> onFilterChanged;
const _InventoryToolbar({
required this.searchController,
required this.activeFilter,
required this.itemCount,
required this.onSearchChanged,
required this.onFilterChanged,
});
static const _filters = [
(label: 'All', value: null),
(label: 'In Stock', value: 'inStock'),
(label: 'Low Stock', value: 'lowStock'),
(label: 'Out of Stock', value: 'outOfStock'),
(label: 'Draft', value: 'draft'),
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.md, KcSpacing.md, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search bar
TextField(
controller: searchController,
onChanged: onSearchChanged,
decoration: InputDecoration(
hintText: 'Search by name or SKU…',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Clear search',
onPressed: () {
searchController.clear();
onSearchChanged('');
},
)
: null,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: KcSpacing.sm),
// Filter chips + item count
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _filters.map((f) {
final isSelected = activeFilter == f.value;
return Padding(
padding: const EdgeInsets.only(right: KcSpacing.xs),
child: FilterChip(
label: Text(f.label),
selected: isSelected,
onSelected: (_) => onFilterChanged(f.value),
),
);
}).toList(),
),
),
),
const SizedBox(width: KcSpacing.sm),
Text(
'$itemCount item${itemCount == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
],
),
const SizedBox(height: KcSpacing.sm),
],
),
);
}
}
// Wide layout (master + detail side-by-side)
class _WideLayout extends StatelessWidget {
final InventoryController controller;
final Future<void> Function(InventoryItem) onAdjustQuantity;
final void Function(String sku)? onViewProduct;
const _WideLayout({
required this.controller,
required this.onAdjustQuantity,
required this.onViewProduct,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Master list 40% width
SizedBox(
width: 340,
child: _ItemList(controller: controller, onViewProduct: onViewProduct),
),
const VerticalDivider(width: 1),
// Detail panel remaining width
Expanded(
child: controller.selectedItem == null
? const KcEmptyState(
icon: Icons.inventory_2_outlined,
message: 'Select an item from the list to view details.',
)
: InventoryDetailPanel(
item: controller.selectedItem!,
isUpdating: controller.isUpdating,
onAdjustQuantity: () => onAdjustQuantity(controller.selectedItem!),
onViewProduct: onViewProduct != null
? () => onViewProduct!(controller.selectedItem!.sku)
: null,
),
),
],
);
}
}
// Narrow layout (list only; tap navigates to detail)
class _NarrowLayout extends StatelessWidget {
final InventoryController controller;
final Future<void> Function(InventoryItem) onAdjustQuantity;
final void Function(String sku)? onViewProduct;
const _NarrowLayout({
required this.controller,
required this.onAdjustQuantity,
required this.onViewProduct,
});
@override
Widget build(BuildContext context) {
return _ItemList(
controller: controller,
onViewProduct: onViewProduct,
onTap: (item) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _InventoryDetailPage(
item: item,
controller: controller,
onAdjustQuantity: onAdjustQuantity,
onViewProduct: onViewProduct,
),
),
);
},
);
}
}
// Shared item list
class _ItemList extends StatelessWidget {
final InventoryController controller;
final void Function(String sku)? onViewProduct;
/// Optional tap handler for narrow layout navigation.
final void Function(InventoryItem)? onTap;
const _ItemList({required this.controller, required this.onViewProduct, this.onTap});
@override
Widget build(BuildContext context) {
if (controller.items.isEmpty) {
return const KcEmptyState(
icon: Icons.inventory_2_outlined,
message: 'No items found. Try adjusting your search or filter.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(KcSpacing.md),
itemCount: controller.items.length,
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
itemBuilder: (context, index) {
final item = controller.items[index];
final isSelected = controller.selectedItem?.id == item.id;
return GestureDetector(
onTap: () {
controller.selectItem(item);
onTap?.call(item);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent,
width: 2,
),
),
child: InventoryItemCard(
item: item,
onViewProduct: onViewProduct != null ? () => onViewProduct!(item.sku) : null,
),
),
);
},
);
}
}
// Narrow detail page (pushed on mobile)
class _InventoryDetailPage extends StatelessWidget {
final InventoryItem item;
final InventoryController controller;
final Future<void> Function(InventoryItem) onAdjustQuantity;
final void Function(String sku)? onViewProduct;
const _InventoryDetailPage({
required this.item,
required this.controller,
required this.onAdjustQuantity,
required this.onViewProduct,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(item.name)),
body: AnimatedBuilder(
animation: controller,
builder: (context, _) {
// Use the refreshed item from the controller if available.
final current = controller.selectedItem?.id == item.id ? controller.selectedItem! : item;
return InventoryDetailPanel(
item: current,
isUpdating: controller.isUpdating,
onAdjustQuantity: () => onAdjustQuantity(current),
onViewProduct: onViewProduct != null ? () => onViewProduct!(current.sku) : null,
);
},
),
);
}
}

View File

@ -1,142 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../domain/inventory_item.dart';
/// Result returned by [showAdjustQuantityDialog].
class AdjustQuantityDialogResult {
final int delta;
final String reason;
const AdjustQuantityDialogResult({required this.delta, required this.reason});
}
/// Shows a modal dialog for adjusting the quantity of [item].
///
/// Returns an [AdjustQuantityDialogResult] if the operator confirms, or `null`
/// if they cancel.
Future<AdjustQuantityDialogResult?> showAdjustQuantityDialog(
BuildContext context,
InventoryItem item,
) {
return showDialog<AdjustQuantityDialogResult>(
context: context,
builder: (_) => _AdjustQuantityDialog(item: item),
);
}
// Private dialog widget
class _AdjustQuantityDialog extends StatefulWidget {
final InventoryItem item;
const _AdjustQuantityDialog({required this.item});
@override
State<_AdjustQuantityDialog> createState() => _AdjustQuantityDialogState();
}
class _AdjustQuantityDialogState extends State<_AdjustQuantityDialog> {
final _deltaController = TextEditingController();
final _reasonController = TextEditingController();
final _formKey = GlobalKey<FormState>();
/// Whether the adjustment is an addition (+) or removal ().
bool _isAddition = true;
@override
void dispose() {
_deltaController.dispose();
_reasonController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final currentQty = widget.item.quantityOnHand;
return AlertDialog(
title: const Text('Adjust Quantity'),
content: SizedBox(
width: 360,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Current quantity display
Text(
'Current quantity: $currentQty',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.md),
// Add / Remove toggle
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('Add'), icon: Icon(Icons.add)),
ButtonSegment(value: false, label: Text('Remove'), icon: Icon(Icons.remove)),
],
selected: {_isAddition},
onSelectionChanged: (s) => setState(() => _isAddition = s.first),
),
const SizedBox(height: KcSpacing.md),
// Amount field
TextFormField(
controller: _deltaController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Amount',
hintText: 'e.g. 5',
border: OutlineInputBorder(),
),
autofocus: true,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Enter an amount';
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Must be a positive number';
if (!_isAddition && n > currentQty) {
return 'Cannot remove more than $currentQty';
}
return null;
},
),
const SizedBox(height: KcSpacing.md),
// Reason field
TextFormField(
controller: _reasonController,
decoration: const InputDecoration(
labelText: 'Reason',
hintText: 'e.g. restock, sold, damaged',
border: OutlineInputBorder(),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Enter a reason';
return null;
},
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(onPressed: _submit, child: const Text('Confirm')),
],
);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final amount = int.parse(_deltaController.text.trim());
final delta = _isAddition ? amount : -amount;
Navigator.of(
context,
).pop(AdjustQuantityDialogResult(delta: delta, reason: _reasonController.text.trim()));
}
}

View File

@ -1,147 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/inventory_item.dart';
import 'inventory_status_chip.dart';
/// A detail panel that displays full information for a selected [InventoryItem]
/// and exposes callbacks for quantity adjustment and item editing.
class InventoryDetailPanel extends StatelessWidget {
final InventoryItem item;
final bool isUpdating;
/// Called when the operator taps "Adjust Quantity".
final VoidCallback? onAdjustQuantity;
/// Called when the operator taps "View Product" (cross-feature handoff).
final VoidCallback? onViewProduct;
const InventoryDetailPanel({
super.key,
required this.item,
required this.isUpdating,
this.onAdjustQuantity,
this.onViewProduct,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(KcSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(item.name, style: theme.textTheme.headlineSmall)),
InventoryStatusChip(status: item.status),
],
),
const SizedBox(height: KcSpacing.xs),
Text(
'SKU: ${item.sku}',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.md),
// Description
if (item.description.isNotEmpty) ...[
Text(item.description, style: theme.textTheme.bodyMedium),
const SizedBox(height: KcSpacing.md),
],
const Divider(),
const SizedBox(height: KcSpacing.md),
// Stock info
KcSectionHeader(title: 'Stock'),
const SizedBox(height: KcSpacing.sm),
_InfoRow(label: 'Quantity on hand', value: '${item.quantityOnHand}'),
_InfoRow(label: 'Reorder point', value: '${item.reorderPoint}'),
_InfoRow(label: 'Unit price', value: '\$${item.unitPrice.toStringAsFixed(2)}'),
const SizedBox(height: KcSpacing.md),
// Location / supplier
if (item.location != null || item.supplier != null) ...[
KcSectionHeader(title: 'Storage'),
const SizedBox(height: KcSpacing.sm),
if (item.location != null) _InfoRow(label: 'Location', value: item.location!),
if (item.supplier != null) _InfoRow(label: 'Supplier', value: item.supplier!),
const SizedBox(height: KcSpacing.md),
],
// Category
if (item.category.isNotEmpty) ...[
KcSectionHeader(title: 'Category'),
const SizedBox(height: KcSpacing.sm),
Text(item.category, style: theme.textTheme.bodyMedium),
const SizedBox(height: KcSpacing.md),
],
// Last updated
Text(
'Last updated: ${_formatDate(item.lastUpdated)}',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.xl),
// Actions
if (isUpdating)
const Center(child: CircularProgressIndicator())
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: onAdjustQuantity,
icon: const Icon(Icons.tune),
label: const Text('Adjust Quantity'),
),
if (onViewProduct != null) ...[
const SizedBox(height: KcSpacing.sm),
OutlinedButton.icon(
onPressed: onViewProduct,
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('View Product'),
),
],
],
),
],
),
);
}
static String _formatDate(DateTime dt) =>
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
// Private helpers
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: KcSpacing.xs),
child: Row(
children: [
SizedBox(
width: 140,
child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral)),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
);
}
}

View File

@ -1,29 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/inventory_status.dart';
/// A status chip for an [InventoryStatus] value, using the shared [KcStatusChip].
class InventoryStatusChip extends StatelessWidget {
final InventoryStatus status;
const InventoryStatusChip({super.key, required this.status});
@override
Widget build(BuildContext context) {
final (label, bg, fg) = _style(status);
return KcStatusChip(label: label, background: bg, foreground: fg);
}
static (String, Color, Color) _style(InventoryStatus status) {
switch (status) {
case InventoryStatus.inStock:
return ('In Stock', const Color(0xFFE8F5E9), KcColors.success);
case InventoryStatus.lowStock:
return ('Low Stock', const Color(0xFFFFF8E1), KcColors.warning);
case InventoryStatus.outOfStock:
return ('Out of Stock', const Color(0xFFFFEBEE), KcColors.danger);
case InventoryStatus.draft:
return ('Draft', const Color(0xFFECEFF1), KcColors.neutral);
}
}
}

View File

@ -1,259 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Helpers
Widget _wrap(Widget child) {
return MaterialApp(
theme: buildKcTheme(),
home: Scaffold(body: child),
);
}
InventoryItem _makeItem({
String id = 'test-1',
String sku = 'TEST-001',
String name = 'Test Item',
String description = 'A test item description',
String imageUrl = '',
String category = 'Test Category',
int quantityOnHand = 10,
double unitPrice = 9.99,
int reorderPoint = 3,
String? supplier,
String? location,
InventoryStatus status = InventoryStatus.inStock,
DateTime? lastUpdated,
}) {
return InventoryItem(
id: id,
sku: sku,
name: name,
description: description,
imageUrl: imageUrl,
category: category,
quantityOnHand: quantityOnHand,
unitPrice: unitPrice,
reorderPoint: reorderPoint,
supplier: supplier,
location: location,
status: status,
lastUpdated: lastUpdated ?? DateTime(2026, 7, 1),
);
}
// InventoryStatusChip
void main() {
group('InventoryStatusChip', () {
testWidgets('shows In Stock label for inStock status', (tester) async {
await tester.pumpWidget(_wrap(const InventoryStatusChip(status: InventoryStatus.inStock)));
expect(find.text('In Stock'), findsOneWidget);
});
testWidgets('shows Low Stock label for lowStock status', (tester) async {
await tester.pumpWidget(_wrap(const InventoryStatusChip(status: InventoryStatus.lowStock)));
expect(find.text('Low Stock'), findsOneWidget);
});
testWidgets('shows Out of Stock label for outOfStock status', (tester) async {
await tester.pumpWidget(_wrap(const InventoryStatusChip(status: InventoryStatus.outOfStock)));
expect(find.text('Out of Stock'), findsOneWidget);
});
testWidgets('shows Draft label for draft status', (tester) async {
await tester.pumpWidget(_wrap(const InventoryStatusChip(status: InventoryStatus.draft)));
expect(find.text('Draft'), findsOneWidget);
});
});
// InventoryDetailPanel
group('InventoryDetailPanel', () {
testWidgets('displays item name and SKU', (tester) async {
final item = _makeItem(name: 'Floral Bowl Cozy', sku: 'BC-FLR-001');
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: false)));
expect(find.text('Floral Bowl Cozy'), findsOneWidget);
expect(find.text('SKU: BC-FLR-001'), findsOneWidget);
});
testWidgets('displays description when non-empty', (tester) async {
final item = _makeItem(description: 'Hand-sewn fabric bowl cozy.');
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: false)));
expect(find.text('Hand-sewn fabric bowl cozy.'), findsOneWidget);
});
testWidgets('displays quantity on hand', (tester) async {
final item = _makeItem(quantityOnHand: 18);
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: false)));
expect(find.text('18'), findsOneWidget);
});
testWidgets('displays unit price formatted', (tester) async {
final item = _makeItem(unitPrice: 12.99);
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: false)));
expect(find.text('\$12.99'), findsOneWidget);
});
testWidgets('shows Adjust Quantity button when not updating', (tester) async {
final item = _makeItem();
await tester.pumpWidget(
_wrap(InventoryDetailPanel(item: item, isUpdating: false, onAdjustQuantity: () {})),
);
expect(find.text('Adjust Quantity'), findsOneWidget);
});
testWidgets('shows loading indicator when isUpdating is true', (tester) async {
final item = _makeItem();
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: true)));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Adjust Quantity'), findsNothing);
});
testWidgets('calls onAdjustQuantity when button tapped', (tester) async {
var called = false;
final item = _makeItem();
await tester.pumpWidget(
_wrap(
InventoryDetailPanel(
item: item,
isUpdating: false,
onAdjustQuantity: () => called = true,
),
),
);
await tester.tap(find.text('Adjust Quantity'));
expect(called, true);
});
testWidgets('shows View Product button when onViewProduct provided', (tester) async {
final item = _makeItem();
await tester.pumpWidget(
_wrap(InventoryDetailPanel(item: item, isUpdating: false, onViewProduct: () {})),
);
expect(find.text('View Product'), findsOneWidget);
});
testWidgets('hides View Product button when onViewProduct is null', (tester) async {
final item = _makeItem();
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: false)));
expect(find.text('View Product'), findsNothing);
});
testWidgets('shows location and supplier when provided', (tester) async {
final item = _makeItem(location: 'Shelf A1', supplier: 'Kell Creations');
await tester.pumpWidget(
_wrap(SingleChildScrollView(child: InventoryDetailPanel(item: item, isUpdating: false))),
);
await tester.pump();
expect(find.text('Shelf A1'), findsOneWidget);
expect(find.text('Kell Creations'), findsOneWidget);
});
testWidgets('shows status chip', (tester) async {
final item = _makeItem(status: InventoryStatus.lowStock);
await tester.pumpWidget(_wrap(InventoryDetailPanel(item: item, isUpdating: false)));
expect(find.text('Low Stock'), findsOneWidget);
});
});
// InventoryPage
group('InventoryPage', () {
testWidgets('shows loading state initially', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
// Pump one frame the async load has started but not completed.
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Let the pending timer complete so the test teardown is clean.
await tester.pumpAndSettle();
});
testWidgets('shows search bar after load', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('shows filter chips after load', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
await tester.pumpAndSettle();
expect(find.byType(FilterChip), findsWidgets);
expect(find.text('All'), findsOneWidget);
expect(find.text('In Stock'), findsOneWidget);
expect(find.text('Low Stock'), findsOneWidget);
});
testWidgets('shows item count after load', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
await tester.pumpAndSettle();
expect(find.text('6 items'), findsOneWidget);
});
testWidgets('shows item cards after load', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
await tester.pumpAndSettle();
expect(find.byType(InventoryItemCard), findsWidgets);
});
testWidgets('search filters items by name', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'cozy');
await tester.pump();
expect(find.text('1 item'), findsOneWidget);
});
testWidgets('filter chip filters items by status', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo)));
await tester.pumpAndSettle();
await tester.tap(find.text('Low Stock').first);
await tester.pump();
expect(find.text('2 items'), findsOneWidget);
});
testWidgets('applies initialFilter on load', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo, initialFilter: 'inStock')));
await tester.pumpAndSettle();
// 2 inStock items in seed data (items 1 and 4)
expect(find.text('2 items'), findsOneWidget);
});
testWidgets('applies initialQuery on load', (tester) async {
final repo = FakeInventoryRepository();
await tester.pumpWidget(_wrap(InventoryPage(repository: repo, initialQuery: 'ocean')));
await tester.pumpAndSettle();
expect(find.text('1 item'), findsOneWidget);
});
});
// AdjustQuantityDialogResult
group('AdjustQuantityDialogResult', () {
test('stores delta and reason', () {
const result = AdjustQuantityDialogResult(delta: 5, reason: 'restock');
expect(result.delta, 5);
expect(result.reason, 'restock');
});
test('stores negative delta for removal', () {
const result = AdjustQuantityDialogResult(delta: -3, reason: 'sold');
expect(result.delta, -3);
expect(result.reason, 'sold');
});
});
}