Compare commits
3 Commits
b2df206123
...
73fdcf99e7
| Author | SHA1 | Date |
|---|---|---|
|
|
73fdcf99e7 | |
|
|
44689b5cdb | |
|
|
f2b29d47a1 |
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/inventory-domain-expansion (Stage 5A complete)
|
- main baseline updated through: feat/inventory-page-ui (Stage 5B complete)
|
||||||
- next branch: feat/inventory-page-ui (Stage 5B) or feat/orders-domain (Stage 5C)
|
- next branch: feat/orders-domain (Stage 5C) or feat/bulk-operator-workflows (Stage 7B)
|
||||||
- current stage: Stage 5A complete — inventory domain expansion with write actions
|
- current stage: Stage 5B complete — inventory page UI with search, filter chips, detail panel, quantity adjust dialog, and snack bar feedback
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -266,6 +266,25 @@
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -125,16 +125,18 @@ 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: `410/410 passed`
|
- total: `485/485 passed`
|
||||||
- baseline commit: merge of `feat/bulk-status-actions` + analyze cleanup (2026-05-30)
|
- baseline commit: merge of `feat/inventory-page-ui` (Stage 5B complete, 2026-07-10)
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
|
@ -150,8 +152,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review), or **`feat/integrations-contracts`** — Stage 8A: Integration abstractions.
|
**`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).
|
||||||
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 9–16).
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,9 @@ 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';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,18 @@ 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;
|
||||||
|
|
||||||
|
|
@ -39,85 +48,368 @@ 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.load().then((_) {
|
_controller.addListener(_onControllerChanged);
|
||||||
// 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.dispose();
|
_controller.removeListener(_onControllerChanged);
|
||||||
|
_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 Center(child: CircularProgressIndicator());
|
return const KcLoadingState(message: 'Loading inventory…');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controller.error != null) {
|
if (_controller.error != null) {
|
||||||
return const Center(child: Text('Failed to load inventory data.'));
|
return KcErrorState(message: 'Failed to load inventory data.', onRetry: _controller.load);
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final isWide = constraints.maxWidth >= KcBreakpoints.medium;
|
||||||
|
|
||||||
int crossAxisCount = 1;
|
return Column(
|
||||||
if (width >= 1200) {
|
children: [
|
||||||
crossAxisCount = 3;
|
_InventoryToolbar(
|
||||||
} else if (width >= 700) {
|
searchController: _searchController,
|
||||||
crossAxisCount = 2;
|
activeFilter: _controller.activeFilter,
|
||||||
|
itemCount: _controller.items.length,
|
||||||
|
onSearchChanged: (q) {
|
||||||
|
_controller.setSearchQuery(q);
|
||||||
|
},
|
||||||
|
onFilterChanged: _controller.setFilter,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: isWide
|
||||||
|
? _WideLayout(
|
||||||
|
controller: _controller,
|
||||||
|
onAdjustQuantity: _handleAdjustQuantity,
|
||||||
|
onViewProduct: widget.onViewProduct,
|
||||||
|
)
|
||||||
|
: _NarrowLayout(
|
||||||
|
controller: _controller,
|
||||||
|
onAdjustQuantity: _handleAdjustQuantity,
|
||||||
|
onViewProduct: widget.onViewProduct,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return GridView.builder(
|
// ── Toolbar ────────────────────────────────────────────────────────────────────
|
||||||
itemCount: controller.items.length,
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
class _InventoryToolbar extends StatelessWidget {
|
||||||
crossAxisCount: crossAxisCount,
|
final TextEditingController searchController;
|
||||||
crossAxisSpacing: KcSpacing.md,
|
final String? activeFilter;
|
||||||
mainAxisSpacing: KcSpacing.md,
|
final int itemCount;
|
||||||
childAspectRatio: 1.5,
|
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) {
|
itemBuilder: (context, index) {
|
||||||
final item = controller.items[index];
|
final item = controller.items[index];
|
||||||
return InventoryItemCard(
|
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,
|
item: item,
|
||||||
onViewProduct: widget.onViewProduct != null
|
onViewProduct: onViewProduct != null ? () => onViewProduct!(item.sku) : null,
|
||||||
? () => widget.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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue