Compare commits

...

2 Commits

Author SHA1 Message Date
Mike Kell b2df206123 Merge feat/inventory-domain-expansion into main (Stage 5A)
Publish Docs / publish-docs (push) Successful in 1m37s Details
2026-07-10 18:33:14 -04:00
Mike Kell d36c2d1e73 feat(inventory): Stage 5A — inventory domain expansion with write actions 2026-07-10 18:33:02 -04:00
17 changed files with 850 additions and 44 deletions

View File

@ -2,9 +2,9 @@
## Current status ## Current status
- main baseline updated through: bulk-status-actions (Stage 7A complete) - main baseline updated through: feat/inventory-domain-expansion (Stage 5A complete)
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/integrations-contracts (Stage 8A) - next branch: feat/inventory-page-ui (Stage 5B) or feat/orders-domain (Stage 5C)
- current stage: Stage 7A complete — deciding next slice - current stage: Stage 5A complete — inventory domain expansion with write actions
## Slice tracker ## Slice tracker
@ -242,9 +242,33 @@
- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files) - analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files)
- brief updated: yes - brief updated: yes
### feat/inventory-domain-expansion
- status: merged to main
- date: 2026-07-10
- inspection: complete
- implementation: complete
- files changed:
- `feature_inventory/lib/src/domain/inventory_item.dart` — expanded model with `description`, `imageUrl`, `category`, `reorderPoint`, `supplier`, `location`, `lastUpdated` fields; added `copyWith()`
- `feature_inventory/lib/src/domain/inventory_adjustment_result.dart` — new value object with `success`, `updatedItem`, `errorMessage`; named constructors `success()` and `failure()`
- `feature_inventory/lib/src/domain/inventory_repository.dart` — expanded contract with `adjustQuantity()`, `setQuantity()`, `createItem()`, `updateItem()`, `updateItemImage()`
- `feature_inventory/lib/src/data/fake_inventory_repository.dart` — expanded seed data (6 items with full fields, supplier/location, reorderPoint); implemented all write methods with status auto-update logic
- `feature_inventory/lib/src/application/adjust_inventory_quantity.dart` — new use case wrapping `adjustQuantity()`
- `feature_inventory/lib/src/application/create_inventory_item.dart` — new use case wrapping `createItem()`
- `feature_inventory/lib/src/application/update_inventory_item.dart` — new use case wrapping `updateItem()`
- `feature_inventory/lib/src/application/inventory_controller.dart` — added `isUpdating`, `lastActionResult`, `adjustQuantity()`, `createItem()`, `updateItem()`, `_refreshItem()` with pre-filter selectedItem refresh
- `feature_inventory/lib/src/presentation/inventory_page.dart` — updated `InventoryController` constructor call to pass all 4 use cases
- `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for all new types and use cases
- `feature_inventory/test/feature_inventory_test.dart` — 49 tests covering InventoryStatus, InventoryItem, InventoryAdjustmentResult, FakeInventoryRepository (all write methods), and InventoryController (read + write actions)
- `kell_web/test/dashboard/domain/dashboard_summary_test.dart` — updated InventoryItem construction to use expanded model
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated stub InventoryRepository to implement expanded contract
- tests: passed (49/49 feature_inventory, 24/24 kell_web, 20/20 core, 41/41 design_system, 311/311 feature_wordpress — 445 total)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes
### chore/analyze-cleanup-and-tracker-sync ### chore/analyze-cleanup-and-tracker-sync
- status: in progress - status: merged to main
- date: 2026-05-30 - date: 2026-05-30
- inspection: complete - inspection: complete
- implementation: complete - implementation: complete

View File

@ -185,10 +185,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -246,10 +246,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -270,10 +270,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.2.0"
web: web:
dependency: transitive dependency: transitive
description: description:

View File

@ -185,10 +185,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -246,10 +246,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -270,10 +270,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.2.0"
web: web:
dependency: transitive dependency: transitive
description: description:

View File

@ -14,6 +14,24 @@ class _StubInventoryRepository implements InventoryRepository {
@override @override
Future<List<InventoryItem>> getInventoryItems() async => items; Future<List<InventoryItem>> getInventoryItems() async => items;
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) =>
throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) =>
throw UnimplementedError();
@override
Future<InventoryItem> createItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryItem> updateItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
throw UnimplementedError();
} }
class _StubProductPublishingRepository implements ProductPublishingRepository { class _StubProductPublishingRepository implements ProductPublishingRepository {
@ -56,6 +74,52 @@ class _StubOrdersRepository implements OrdersRepository {
class _FailingInventoryRepository implements InventoryRepository { class _FailingInventoryRepository implements InventoryRepository {
@override @override
Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error'); Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error');
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) =>
throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) =>
throw UnimplementedError();
@override
Future<InventoryItem> createItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryItem> updateItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
throw UnimplementedError();
}
// Shared test helpers
final _baseDate = DateTime(2026, 7, 1);
InventoryItem _inv({
required String id,
required String sku,
required String name,
required int quantityOnHand,
required double unitPrice,
required InventoryStatus status,
int reorderPoint = 5,
}) {
return InventoryItem(
id: id,
sku: sku,
name: name,
description: '',
imageUrl: '',
category: 'Test',
quantityOnHand: quantityOnHand,
unitPrice: unitPrice,
reorderPoint: reorderPoint,
status: status,
lastUpdated: _baseDate,
);
} }
// Tests // Tests
@ -78,7 +142,7 @@ void main() {
test('load() sets isLoading then populates summary', () async { test('load() sets isLoading then populates summary', () async {
final inventoryItems = [ final inventoryItems = [
const InventoryItem( _inv(
id: '1', id: '1',
sku: 'A', sku: 'A',
name: 'A', name: 'A',
@ -86,7 +150,7 @@ void main() {
unitPrice: 5.0, unitPrice: 5.0,
status: InventoryStatus.inStock, status: InventoryStatus.inStock,
), ),
const InventoryItem( _inv(
id: '2', id: '2',
sku: 'B', sku: 'B',
name: 'B', name: 'B',
@ -138,7 +202,7 @@ void main() {
test('load() aggregates data from all three repositories', () async { test('load() aggregates data from all three repositories', () async {
final inventoryItems = [ final inventoryItems = [
const InventoryItem( _inv(
id: '1', id: '1',
sku: 'A', sku: 'A',
name: 'A', name: 'A',

View File

@ -4,6 +4,34 @@ import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/dashboard/domain/dashboard_summary.dart'; import 'package:kell_web/dashboard/domain/dashboard_summary.dart';
// Shared test helpers
final _baseDate = DateTime(2026, 7, 1);
InventoryItem _inv({
required String id,
required String sku,
required String name,
required int quantityOnHand,
required double unitPrice,
required InventoryStatus status,
int reorderPoint = 5,
}) {
return InventoryItem(
id: id,
sku: sku,
name: name,
description: '',
imageUrl: '',
category: 'Test',
quantityOnHand: quantityOnHand,
unitPrice: unitPrice,
reorderPoint: reorderPoint,
status: status,
lastUpdated: _baseDate,
);
}
void main() { void main() {
group('DashboardSummary.empty', () { group('DashboardSummary.empty', () {
test('has all zeroes', () { test('has all zeroes', () {
@ -23,7 +51,7 @@ void main() {
group('DashboardSummary.fromData', () { group('DashboardSummary.fromData', () {
test('counts inventory statuses correctly', () { test('counts inventory statuses correctly', () {
final items = [ final items = [
const InventoryItem( _inv(
id: '1', id: '1',
sku: 'A', sku: 'A',
name: 'A', name: 'A',
@ -31,7 +59,7 @@ void main() {
unitPrice: 5.0, unitPrice: 5.0,
status: InventoryStatus.inStock, status: InventoryStatus.inStock,
), ),
const InventoryItem( _inv(
id: '2', id: '2',
sku: 'B', sku: 'B',
name: 'B', name: 'B',
@ -39,7 +67,7 @@ void main() {
unitPrice: 5.0, unitPrice: 5.0,
status: InventoryStatus.lowStock, status: InventoryStatus.lowStock,
), ),
const InventoryItem( _inv(
id: '3', id: '3',
sku: 'C', sku: 'C',
name: 'C', name: 'C',
@ -47,7 +75,7 @@ void main() {
unitPrice: 5.0, unitPrice: 5.0,
status: InventoryStatus.outOfStock, status: InventoryStatus.outOfStock,
), ),
const InventoryItem( _inv(
id: '4', id: '4',
sku: 'D', sku: 'D',
name: 'D', name: 'D',
@ -177,10 +205,9 @@ void main() {
}); });
test('computes full summary from fake repository data', () { test('computes full summary from fake repository data', () {
// Use the same data the fake repositories return.
final summary = DashboardSummary.fromData( final summary = DashboardSummary.fromData(
inventoryItems: const [ inventoryItems: [
InventoryItem( _inv(
id: '1', id: '1',
sku: 'BC-FLR-001', sku: 'BC-FLR-001',
name: 'Floral Bowl Cozy', name: 'Floral Bowl Cozy',
@ -188,15 +215,16 @@ void main() {
unitPrice: 12.99, unitPrice: 12.99,
status: InventoryStatus.inStock, status: InventoryStatus.inStock,
), ),
InventoryItem( _inv(
id: '2', id: '2',
sku: 'CS-CIT-002', sku: 'CS-CIT-002',
name: 'Citrus Coaster Set', name: 'Citrus Coaster Set',
quantityOnHand: 7, quantityOnHand: 7,
unitPrice: 16.50, unitPrice: 16.50,
status: InventoryStatus.lowStock, status: InventoryStatus.lowStock,
reorderPoint: 10,
), ),
InventoryItem( _inv(
id: '3', id: '3',
sku: 'NL-OCN-003', sku: 'NL-OCN-003',
name: 'Ocean Nightlight', name: 'Ocean Nightlight',
@ -204,7 +232,7 @@ void main() {
unitPrice: 19.99, unitPrice: 19.99,
status: InventoryStatus.outOfStock, status: InventoryStatus.outOfStock,
), ),
InventoryItem( _inv(
id: '4', id: '4',
sku: 'JG-BLU-004', sku: 'JG-BLU-004',
name: 'Fabric Jar Gripper', name: 'Fabric Jar Gripper',
@ -212,7 +240,7 @@ void main() {
unitPrice: 8.50, unitPrice: 8.50,
status: InventoryStatus.inStock, status: InventoryStatus.inStock,
), ),
InventoryItem( _inv(
id: '5', id: '5',
sku: 'SH-SUN-005', sku: 'SH-SUN-005',
name: 'Skillet Handle Sleeve', name: 'Skillet Handle Sleeve',
@ -220,7 +248,7 @@ void main() {
unitPrice: 10.99, unitPrice: 10.99,
status: InventoryStatus.lowStock, status: InventoryStatus.lowStock,
), ),
InventoryItem( _inv(
id: '6', id: '6',
sku: 'SC-SUB-006', sku: 'SC-SUB-006',
name: 'Sublimated Slate Coaster', name: 'Sublimated Slate Coaster',

View File

@ -1,6 +1,12 @@
library; library;
export 'src/application/adjust_inventory_quantity.dart';
export 'src/application/create_inventory_item.dart';
export 'src/application/get_inventory_items.dart';
export 'src/application/inventory_controller.dart';
export 'src/application/update_inventory_item.dart';
export 'src/data/fake_inventory_repository.dart'; export 'src/data/fake_inventory_repository.dart';
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';

View File

@ -0,0 +1,14 @@
import '../domain/inventory_adjustment_result.dart';
import '../domain/inventory_repository.dart';
/// Use case: adjust the quantity of an inventory item by a delta.
///
/// A positive [delta] adds stock; a negative [delta] removes stock.
class AdjustInventoryQuantity {
final InventoryRepository repository;
AdjustInventoryQuantity(this.repository);
Future<InventoryAdjustmentResult> call(String id, int delta, String reason) =>
repository.adjustQuantity(id, delta, reason);
}

View File

@ -0,0 +1,11 @@
import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart';
/// Use case: create a new inventory item.
class CreateInventoryItem {
final InventoryRepository repository;
CreateInventoryItem(this.repository);
Future<InventoryItem> call(InventoryItem item) => repository.createItem(item);
}

View File

@ -1,18 +1,34 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../domain/inventory_adjustment_result.dart';
import '../domain/inventory_item.dart'; import '../domain/inventory_item.dart';
import '../domain/inventory_status.dart'; import '../domain/inventory_status.dart';
import 'adjust_inventory_quantity.dart';
import 'create_inventory_item.dart';
import 'get_inventory_items.dart'; import 'get_inventory_items.dart';
import 'update_inventory_item.dart';
/// Controller that manages the inventory workspace state, including /// Controller that manages the inventory workspace state, including
/// filtering by status, free-text search, and item selection. /// filtering by status, free-text search, item selection, and write actions.
class InventoryController extends ChangeNotifier { class InventoryController extends ChangeNotifier {
final GetInventoryItems _getInventoryItems; final GetInventoryItems _getInventoryItems;
final AdjustInventoryQuantity _adjustInventoryQuantity;
final CreateInventoryItem _createInventoryItem;
final UpdateInventoryItem _updateInventoryItem;
InventoryController(this._getInventoryItems); InventoryController(
this._getInventoryItems,
this._adjustInventoryQuantity,
this._createInventoryItem,
this._updateInventoryItem,
);
bool isLoading = false; bool isLoading = false;
bool isUpdating = false;
Object? error; Object? error;
/// The last write action result, or `null` if no write has been performed.
InventoryAdjustmentResult? lastActionResult;
/// All items returned by the repository (unfiltered). /// All items returned by the repository (unfiltered).
List<InventoryItem> _allItems = []; List<InventoryItem> _allItems = [];
@ -78,7 +94,83 @@ class InventoryController extends ChangeNotifier {
return false; return false;
} }
// Private helpers // Write actions
/// Adjusts the quantity of the item with [id] by [delta].
///
/// A positive [delta] adds stock; a negative [delta] removes stock.
/// Sets [lastActionResult] and refreshes the list on success.
Future<void> adjustQuantity(String id, int delta, String reason) async {
isUpdating = true;
lastActionResult = null;
notifyListeners();
try {
final result = await _adjustInventoryQuantity(id, delta, reason);
lastActionResult = result;
if (result.success) {
await _refreshItem(result.updatedItem!);
}
} catch (e) {
lastActionResult = InventoryAdjustmentResult.failure(e.toString());
} finally {
isUpdating = false;
notifyListeners();
}
}
/// Creates a new inventory item and adds it to the list.
Future<void> createItem(InventoryItem item) async {
isUpdating = true;
lastActionResult = null;
notifyListeners();
try {
final created = await _createInventoryItem(item);
_allItems = [..._allItems, created];
_applyFilters();
lastActionResult = InventoryAdjustmentResult.success(created);
} catch (e) {
lastActionResult = InventoryAdjustmentResult.failure(e.toString());
} finally {
isUpdating = false;
notifyListeners();
}
}
/// Updates an existing inventory item and refreshes the list.
Future<void> updateItem(InventoryItem item) async {
isUpdating = true;
lastActionResult = null;
notifyListeners();
try {
final updated = await _updateInventoryItem(item);
await _refreshItem(updated);
lastActionResult = InventoryAdjustmentResult.success(updated);
} catch (e) {
lastActionResult = InventoryAdjustmentResult.failure(e.toString());
} finally {
isUpdating = false;
notifyListeners();
}
}
// Private helpers
/// Replaces the item in [_allItems] and re-applies filters.
/// Also refreshes [selectedItem] if it matches the updated item.
Future<void> _refreshItem(InventoryItem updated) async {
// Update selectedItem BEFORE _applyFilters so the contains() check passes.
if (selectedItem?.id == updated.id) {
selectedItem = updated;
}
_allItems = [
for (final i in _allItems)
if (i.id == updated.id) updated else i,
];
_applyFilters();
}
void _applyFilters() { void _applyFilters() {
var result = _allItems; var result = _allItems;

View File

@ -0,0 +1,11 @@
import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart';
/// Use case: update an existing inventory item.
class UpdateInventoryItem {
final InventoryRepository repository;
UpdateInventoryItem(this.repository);
Future<InventoryItem> call(InventoryItem item) => repository.updateItem(item);
}

View File

@ -1,61 +1,191 @@
import '../domain/inventory_adjustment_result.dart';
import '../domain/inventory_item.dart'; import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart'; import '../domain/inventory_repository.dart';
import '../domain/inventory_status.dart'; import '../domain/inventory_status.dart';
class FakeInventoryRepository implements InventoryRepository { class FakeInventoryRepository implements InventoryRepository {
@override /// Mutable in-memory store seeded on construction.
Future<List<InventoryItem>> getInventoryItems() async { final List<InventoryItem> _items = _buildSeedData();
await Future<void>.delayed(const Duration(milliseconds: 300));
return const [ static List<InventoryItem> _buildSeedData() {
final now = DateTime(2026, 7, 1);
return [
InventoryItem( InventoryItem(
id: '1', id: '1',
sku: 'BC-FLR-001', sku: 'BC-FLR-001',
name: 'Floral Bowl Cozy', name: 'Floral Bowl Cozy',
description: 'Hand-sewn fabric bowl cozy with floral print. Microwave safe.',
imageUrl: 'https://example.com/images/bc-flr-001.jpg',
category: 'Kitchen Accessories',
quantityOnHand: 18, quantityOnHand: 18,
unitPrice: 12.99, unitPrice: 12.99,
reorderPoint: 5,
supplier: 'Kell Creations',
location: 'Shelf A1',
status: InventoryStatus.inStock, status: InventoryStatus.inStock,
lastUpdated: now,
), ),
InventoryItem( InventoryItem(
id: '2', id: '2',
sku: 'CS-CIT-002', sku: 'CS-CIT-002',
name: 'Citrus Coaster Set', name: 'Citrus Coaster Set',
description: 'Set of 4 sublimated slate coasters with citrus fruit designs.',
imageUrl: 'https://example.com/images/cs-cit-002.jpg',
category: 'Home Decor',
quantityOnHand: 7, quantityOnHand: 7,
unitPrice: 16.50, unitPrice: 16.50,
reorderPoint: 10,
supplier: 'Kell Creations',
location: 'Shelf B2',
status: InventoryStatus.lowStock, status: InventoryStatus.lowStock,
lastUpdated: now,
), ),
InventoryItem( InventoryItem(
id: '3', id: '3',
sku: 'NL-OCN-003', sku: 'NL-OCN-003',
name: 'Ocean Nightlight', name: 'Ocean Nightlight',
description: 'Sublimated acrylic nightlight with ocean wave design.',
imageUrl: 'https://example.com/images/nl-ocn-003.jpg',
category: 'Home Decor',
quantityOnHand: 0, quantityOnHand: 0,
unitPrice: 19.99, unitPrice: 19.99,
reorderPoint: 3,
supplier: null,
location: 'Shelf C1',
status: InventoryStatus.outOfStock, status: InventoryStatus.outOfStock,
lastUpdated: now,
), ),
InventoryItem( InventoryItem(
id: '4', id: '4',
sku: 'JG-BLU-004', sku: 'JG-BLU-004',
name: 'Fabric Jar Gripper', name: 'Fabric Jar Gripper',
description: 'Quilted fabric jar gripper for easy lid opening.',
imageUrl: 'https://example.com/images/jg-blu-004.jpg',
category: 'Kitchen Accessories',
quantityOnHand: 23, quantityOnHand: 23,
unitPrice: 8.50, unitPrice: 8.50,
reorderPoint: 8,
supplier: 'Kell Creations',
location: 'Shelf A2',
status: InventoryStatus.inStock, status: InventoryStatus.inStock,
lastUpdated: now,
), ),
InventoryItem( InventoryItem(
id: '5', id: '5',
sku: 'SH-SUN-005', sku: 'SH-SUN-005',
name: 'Skillet Handle Sleeve', name: 'Skillet Handle Sleeve',
description: 'Heat-resistant fabric sleeve for cast iron skillet handles.',
imageUrl: 'https://example.com/images/sh-sun-005.jpg',
category: 'Kitchen Accessories',
quantityOnHand: 5, quantityOnHand: 5,
unitPrice: 10.99, unitPrice: 10.99,
reorderPoint: 8,
supplier: 'Kell Creations',
location: null,
status: InventoryStatus.lowStock, status: InventoryStatus.lowStock,
lastUpdated: now,
), ),
InventoryItem( InventoryItem(
id: '6', id: '6',
sku: 'SC-SUB-006', sku: 'SC-SUB-006',
name: 'Sublimated Slate Coaster', name: 'Sublimated Slate Coaster',
description: 'Single sublimated slate coaster — prototype/draft item.',
imageUrl: '',
category: 'Home Decor',
quantityOnHand: 0, quantityOnHand: 0,
unitPrice: 14.99, unitPrice: 14.99,
reorderPoint: 5,
supplier: null,
location: null,
status: InventoryStatus.draft, status: InventoryStatus.draft,
lastUpdated: now,
), ),
]; ];
} }
@override
Future<List<InventoryItem>> getInventoryItems() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return List.unmodifiable(_items);
}
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final item = _items[index];
final newQty = item.quantityOnHand + delta;
if (newQty < 0) {
return const InventoryAdjustmentResult.failure('Quantity cannot go below zero');
}
final updated = item.copyWith(
quantityOnHand: newQty,
status: _deriveStatus(newQty, item.reorderPoint),
lastUpdated: DateTime.now(),
);
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
@override
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (quantity < 0) {
return const InventoryAdjustmentResult.failure('Quantity cannot be negative');
}
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final item = _items[index];
final updated = item.copyWith(
quantityOnHand: quantity,
status: _deriveStatus(quantity, item.reorderPoint),
lastUpdated: DateTime.now(),
);
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
@override
Future<InventoryItem> createItem(InventoryItem item) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
_items.add(item);
return item;
}
@override
Future<InventoryItem> updateItem(InventoryItem item) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final index = _items.indexWhere((i) => i.id == item.id);
if (index == -1) {
throw StateError('Item with id ${item.id} not found');
}
final updated = item.copyWith(lastUpdated: DateTime.now());
_items[index] = updated;
return updated;
}
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final updated = _items[index].copyWith(imageUrl: imageUrl, lastUpdated: DateTime.now());
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
// Private helpers
static InventoryStatus _deriveStatus(int quantity, int reorderPoint) {
if (quantity == 0) return InventoryStatus.outOfStock;
if (quantity <= reorderPoint) return InventoryStatus.lowStock;
return InventoryStatus.inStock;
}
} }

View File

@ -0,0 +1,23 @@
import 'inventory_item.dart';
/// The result of an inventory quantity adjustment or write operation.
class InventoryAdjustmentResult {
/// Whether the operation succeeded.
final bool success;
/// The updated [InventoryItem] after the operation, or `null` on failure.
final InventoryItem? updatedItem;
/// A human-readable error message when [success] is `false`.
final String? errorMessage;
const InventoryAdjustmentResult.success(InventoryItem item)
: success = true,
updatedItem = item,
errorMessage = null;
const InventoryAdjustmentResult.failure(String message)
: success = false,
updatedItem = null,
errorMessage = message;
}

View File

@ -1,19 +1,67 @@
import 'inventory_status.dart'; import 'inventory_status.dart';
/// A single inventory item tracked by the Kell Creations operations platform.
class InventoryItem { class InventoryItem {
final String id; final String id;
final String sku; final String sku;
final String name; final String name;
final String description;
final String imageUrl;
final String category;
final int quantityOnHand; final int quantityOnHand;
final double unitPrice; final double unitPrice;
final int reorderPoint;
final String? supplier;
final String? location;
final InventoryStatus status; final InventoryStatus status;
final DateTime lastUpdated;
const InventoryItem({ const InventoryItem({
required this.id, required this.id,
required this.sku, required this.sku,
required this.name, required this.name,
required this.description,
required this.imageUrl,
required this.category,
required this.quantityOnHand, required this.quantityOnHand,
required this.unitPrice, required this.unitPrice,
required this.reorderPoint,
this.supplier,
this.location,
required this.status, required this.status,
required this.lastUpdated,
}); });
/// Returns a copy of this [InventoryItem] with the given fields replaced.
InventoryItem copyWith({
String? id,
String? sku,
String? name,
String? description,
String? imageUrl,
String? category,
int? quantityOnHand,
double? unitPrice,
int? reorderPoint,
String? supplier,
String? location,
InventoryStatus? status,
DateTime? lastUpdated,
}) {
return InventoryItem(
id: id ?? this.id,
sku: sku ?? this.sku,
name: name ?? this.name,
description: description ?? this.description,
imageUrl: imageUrl ?? this.imageUrl,
category: category ?? this.category,
quantityOnHand: quantityOnHand ?? this.quantityOnHand,
unitPrice: unitPrice ?? this.unitPrice,
reorderPoint: reorderPoint ?? this.reorderPoint,
supplier: supplier ?? this.supplier,
location: location ?? this.location,
status: status ?? this.status,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
} }

View File

@ -1,5 +1,27 @@
import 'inventory_adjustment_result.dart';
import 'inventory_item.dart'; import 'inventory_item.dart';
abstract class InventoryRepository { abstract class InventoryRepository {
/// Returns all inventory items.
Future<List<InventoryItem>> getInventoryItems(); Future<List<InventoryItem>> getInventoryItems();
/// Adjusts the quantity of an item by [delta] (positive = add, negative = remove).
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason);
/// Sets the quantity of an item to an absolute [quantity].
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason);
/// Creates a new inventory item.
/// Returns the created [InventoryItem].
Future<InventoryItem> createItem(InventoryItem item);
/// Updates an existing inventory item with all fields from [item].
/// Returns the updated [InventoryItem].
Future<InventoryItem> updateItem(InventoryItem item);
/// Updates the image URL for an inventory item.
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl);
} }

View File

@ -1,8 +1,11 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../application/adjust_inventory_quantity.dart';
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 '../domain/inventory_repository.dart'; import '../domain/inventory_repository.dart';
import 'widgets/inventory_item_card.dart'; import 'widgets/inventory_item_card.dart';
@ -41,7 +44,12 @@ class _InventoryPageState extends State<InventoryPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = InventoryController(GetInventoryItems(widget.repository)); controller = InventoryController(
GetInventoryItems(widget.repository),
AdjustInventoryQuantity(widget.repository),
CreateInventoryItem(widget.repository),
UpdateInventoryItem(widget.repository),
);
// Apply any initial filter / query before loading. // Apply any initial filter / query before loading.
if (widget.initialFilter != null) { if (widget.initialFilter != null) {

View File

@ -1,5 +1,5 @@
name: feature_inventory name: feature_inventory
description: "A new Flutter package project." description: "Inventory management feature for the Kell Creations operations platform. Provides domain model, repository contract, fake data layer, and write operations for inventory items."
version: 0.0.1 version: 0.0.1
publish_to: "none" publish_to: "none"
homepage: homepage:

View File

@ -1,10 +1,52 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:feature_inventory/feature_inventory.dart'; // Helpers
import 'package:feature_inventory/src/application/get_inventory_items.dart';
import 'package:feature_inventory/src/application/inventory_controller.dart'; InventoryController _makeController(FakeInventoryRepository repo) {
return InventoryController(
GetInventoryItems(repo),
AdjustInventoryQuantity(repo),
CreateInventoryItem(repo),
UpdateInventoryItem(repo),
);
}
InventoryItem _makeItem({
String id = 'test-1',
String sku = 'TEST-001',
String name = 'Test Item',
String description = 'A test item',
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),
);
}
void main() { void main() {
// InventoryStatus
group('InventoryStatus', () { group('InventoryStatus', () {
test('has four values', () { test('has four values', () {
expect(InventoryStatus.values.length, 4); expect(InventoryStatus.values.length, 4);
@ -18,6 +60,88 @@ void main() {
}); });
}); });
// InventoryItem
group('InventoryItem', () {
test('constructs with all required fields', () {
final item = _makeItem();
expect(item.id, 'test-1');
expect(item.sku, 'TEST-001');
expect(item.name, 'Test Item');
expect(item.description, 'A test item');
expect(item.imageUrl, '');
expect(item.category, 'Test Category');
expect(item.quantityOnHand, 10);
expect(item.unitPrice, 9.99);
expect(item.reorderPoint, 3);
expect(item.supplier, isNull);
expect(item.location, isNull);
expect(item.status, InventoryStatus.inStock);
});
test('copyWith returns new instance with updated fields', () {
final original = _makeItem();
final copy = original.copyWith(name: 'Updated', quantityOnHand: 20);
expect(copy.name, 'Updated');
expect(copy.quantityOnHand, 20);
// Unchanged fields preserved
expect(copy.id, original.id);
expect(copy.sku, original.sku);
expect(copy.description, original.description);
expect(copy.category, original.category);
expect(copy.unitPrice, original.unitPrice);
expect(copy.reorderPoint, original.reorderPoint);
expect(copy.status, original.status);
});
test('copyWith preserves nullable fields when not specified', () {
final item = _makeItem(supplier: 'Kell Creations', location: 'Shelf A1');
final copy = item.copyWith(name: 'New Name');
expect(copy.supplier, 'Kell Creations');
expect(copy.location, 'Shelf A1');
});
test('copyWith can update nullable fields', () {
final item = _makeItem();
final copy = item.copyWith(supplier: 'New Supplier', location: 'Bin 3');
expect(copy.supplier, 'New Supplier');
expect(copy.location, 'Bin 3');
});
test('copyWith can update status', () {
final item = _makeItem(status: InventoryStatus.inStock);
final copy = item.copyWith(status: InventoryStatus.lowStock);
expect(copy.status, InventoryStatus.lowStock);
});
test('copyWith can update imageUrl', () {
final item = _makeItem(imageUrl: '');
final copy = item.copyWith(imageUrl: 'https://example.com/img.jpg');
expect(copy.imageUrl, 'https://example.com/img.jpg');
});
});
// InventoryAdjustmentResult
group('InventoryAdjustmentResult', () {
test('success result has correct fields', () {
final item = _makeItem();
final result = InventoryAdjustmentResult.success(item);
expect(result.success, true);
expect(result.updatedItem, item);
expect(result.errorMessage, isNull);
});
test('failure result has correct fields', () {
const result = InventoryAdjustmentResult.failure('Something went wrong');
expect(result.success, false);
expect(result.updatedItem, isNull);
expect(result.errorMessage, 'Something went wrong');
});
});
// FakeInventoryRepository
group('FakeInventoryRepository', () { group('FakeInventoryRepository', () {
late FakeInventoryRepository repository; late FakeInventoryRepository repository;
@ -37,15 +161,134 @@ void main() {
expect(names, contains('Citrus Coaster Set')); expect(names, contains('Citrus Coaster Set'));
expect(names, contains('Ocean Nightlight')); expect(names, contains('Ocean Nightlight'));
}); });
test('seed data includes expanded fields', () async {
final items = await repository.getInventoryItems();
final first = items.first;
expect(first.description, isNotEmpty);
expect(first.category, isNotEmpty);
expect(first.reorderPoint, greaterThan(0));
expect(first.lastUpdated, isNotNull);
}); });
test('seed data has items with supplier and location', () async {
final items = await repository.getInventoryItems();
final withSupplier = items.where((i) => i.supplier != null).toList();
expect(withSupplier, isNotEmpty);
});
test('adjustQuantity adds stock successfully', () async {
final result = await repository.adjustQuantity('1', 5, 'restock');
expect(result.success, true);
expect(result.updatedItem!.quantityOnHand, 23); // 18 + 5
});
test('adjustQuantity removes stock successfully', () async {
final result = await repository.adjustQuantity('1', -3, 'sold');
expect(result.success, true);
expect(result.updatedItem!.quantityOnHand, 15); // 18 - 3
});
test('adjustQuantity fails when result would be negative', () async {
final result = await repository.adjustQuantity('1', -100, 'error');
expect(result.success, false);
expect(result.errorMessage, isNotEmpty);
});
test('adjustQuantity fails for unknown id', () async {
final result = await repository.adjustQuantity('UNKNOWN', 1, 'test');
expect(result.success, false);
});
test('adjustQuantity updates status to outOfStock when qty reaches 0', () async {
// Item 3 has qty 0 already; use item 4 (qty 23, reorderPoint 8)
// Reduce to 0
final result = await repository.adjustQuantity('4', -23, 'clear');
expect(result.success, true);
expect(result.updatedItem!.status, InventoryStatus.outOfStock);
});
test('adjustQuantity updates status to lowStock when qty at or below reorderPoint', () async {
// Item 1: qty 18, reorderPoint 5 reduce to 4 (below reorderPoint)
final result = await repository.adjustQuantity('1', -14, 'reduce');
expect(result.success, true);
expect(result.updatedItem!.status, InventoryStatus.lowStock);
});
test('adjustQuantity updates status to inStock when qty above reorderPoint', () async {
// Item 2: qty 7, reorderPoint 10 add enough to go above reorderPoint
final result = await repository.adjustQuantity('2', 10, 'restock');
expect(result.success, true);
expect(result.updatedItem!.status, InventoryStatus.inStock);
});
test('setQuantity sets absolute quantity', () async {
final result = await repository.setQuantity('1', 50, 'inventory count');
expect(result.success, true);
expect(result.updatedItem!.quantityOnHand, 50);
});
test('setQuantity fails for negative quantity', () async {
final result = await repository.setQuantity('1', -1, 'error');
expect(result.success, false);
});
test('setQuantity fails for unknown id', () async {
final result = await repository.setQuantity('UNKNOWN', 10, 'test');
expect(result.success, false);
});
test('createItem adds item to repository', () async {
final newItem = _makeItem(id: 'new-1', sku: 'NEW-001', name: 'New Item');
await repository.createItem(newItem);
final items = await repository.getInventoryItems();
expect(items.length, 7);
expect(items.any((i) => i.id == 'new-1'), true);
});
test('updateItem updates existing item', () async {
final items = await repository.getInventoryItems();
final original = items.first;
final updated = original.copyWith(name: 'Updated Name', unitPrice: 99.99);
final result = await repository.updateItem(updated);
expect(result.name, 'Updated Name');
expect(result.unitPrice, 99.99);
});
test('updateItem throws for unknown id', () async {
final unknown = _makeItem(id: 'UNKNOWN');
expect(() => repository.updateItem(unknown), throwsStateError);
});
test('updateItemImage updates image URL', () async {
const newUrl = 'https://example.com/new-image.jpg';
final result = await repository.updateItemImage('1', newUrl);
expect(result.success, true);
expect(result.updatedItem!.imageUrl, newUrl);
});
test('updateItemImage fails for unknown id', () async {
final result = await repository.updateItemImage('UNKNOWN', 'url');
expect(result.success, false);
});
test('adjustQuantity persists change across calls', () async {
await repository.adjustQuantity('1', -5, 'sold');
final items = await repository.getInventoryItems();
final item = items.firstWhere((i) => i.id == '1');
expect(item.quantityOnHand, 13); // 18 - 5
});
});
// InventoryController
group('InventoryController', () { group('InventoryController', () {
late FakeInventoryRepository repository; late FakeInventoryRepository repository;
late InventoryController controller; late InventoryController controller;
setUp(() { setUp(() {
repository = FakeInventoryRepository(); repository = FakeInventoryRepository();
controller = InventoryController(GetInventoryItems(repository)); controller = _makeController(repository);
}); });
tearDown(() { tearDown(() {
@ -54,11 +297,13 @@ void main() {
test('starts with empty state', () { test('starts with empty state', () {
expect(controller.isLoading, false); expect(controller.isLoading, false);
expect(controller.isUpdating, false);
expect(controller.items, isEmpty); expect(controller.items, isEmpty);
expect(controller.selectedItem, isNull); expect(controller.selectedItem, isNull);
expect(controller.activeFilter, isNull); expect(controller.activeFilter, isNull);
expect(controller.searchQuery, ''); expect(controller.searchQuery, '');
expect(controller.error, isNull); expect(controller.error, isNull);
expect(controller.lastActionResult, isNull);
}); });
test('load populates items', () async { test('load populates items', () async {
@ -155,5 +400,85 @@ void main() {
controller.setFilter('lowStock'); controller.setFilter('lowStock');
expect(controller.selectedItem, isNull); expect(controller.selectedItem, isNull);
}); });
// Write action tests
test('adjustQuantity succeeds and updates item in list', () async {
await controller.load();
await controller.adjustQuantity('1', 5, 'restock');
expect(controller.isUpdating, false);
expect(controller.lastActionResult, isNotNull);
expect(controller.lastActionResult!.success, true);
final item = controller.items.firstWhere((i) => i.id == '1');
expect(item.quantityOnHand, 23); // 18 + 5
});
test('adjustQuantity failure sets lastActionResult with error', () async {
await controller.load();
await controller.adjustQuantity('1', -1000, 'error');
expect(controller.lastActionResult!.success, false);
expect(controller.lastActionResult!.errorMessage, isNotEmpty);
});
test('adjustQuantity refreshes selectedItem when it is the adjusted item', () async {
await controller.load();
controller.selectBySku('BC-FLR-001');
expect(controller.selectedItem!.quantityOnHand, 18);
await controller.adjustQuantity('1', 2, 'restock');
expect(controller.selectedItem!.quantityOnHand, 20);
});
test('createItem adds item to list', () async {
await controller.load();
expect(controller.items.length, 6);
final newItem = _makeItem(id: 'new-1', sku: 'NEW-001', name: 'New Item');
await controller.createItem(newItem);
expect(controller.items.length, 7);
expect(controller.lastActionResult!.success, true);
});
test('updateItem updates item in list', () async {
await controller.load();
final original = controller.items.firstWhere((i) => i.id == '1');
final updated = original.copyWith(name: 'Updated Name');
await controller.updateItem(updated);
expect(controller.lastActionResult!.success, true);
final inList = controller.items.firstWhere((i) => i.id == '1');
expect(inList.name, 'Updated Name');
});
test('updateItem refreshes selectedItem when it is the updated item', () async {
await controller.load();
controller.selectBySku('BC-FLR-001');
final original = controller.selectedItem!;
final updated = original.copyWith(name: 'Refreshed Name');
await controller.updateItem(updated);
expect(controller.selectedItem!.name, 'Refreshed Name');
});
test('isUpdating is true during write and false after', () async {
await controller.load();
final states = <bool>[];
controller.addListener(() => states.add(controller.isUpdating));
await controller.adjustQuantity('1', 1, 'test');
// First notification: isUpdating = true, last: isUpdating = false
expect(states.first, true);
expect(states.last, false);
});
}); });
} }