Compare commits

..

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

17 changed files with 44 additions and 850 deletions

View File

@ -2,9 +2,9 @@
## Current status
- main baseline updated through: feat/inventory-domain-expansion (Stage 5A complete)
- next branch: feat/inventory-page-ui (Stage 5B) or feat/orders-domain (Stage 5C)
- current stage: Stage 5A complete — inventory domain expansion with write actions
- main baseline updated through: bulk-status-actions (Stage 7A complete)
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/integrations-contracts (Stage 8A)
- current stage: Stage 7A complete — deciding next slice
## Slice tracker
@ -242,33 +242,9 @@
- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files)
- 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
- status: merged to main
- status: in progress
- date: 2026-05-30
- inspection: complete
- implementation: complete

View File

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

View File

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

View File

@ -14,24 +14,6 @@ class _StubInventoryRepository implements InventoryRepository {
@override
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 {
@ -74,52 +56,6 @@ class _StubOrdersRepository implements OrdersRepository {
class _FailingInventoryRepository implements InventoryRepository {
@override
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
@ -142,7 +78,7 @@ void main() {
test('load() sets isLoading then populates summary', () async {
final inventoryItems = [
_inv(
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',
@ -150,7 +86,7 @@ void main() {
unitPrice: 5.0,
status: InventoryStatus.inStock,
),
_inv(
const InventoryItem(
id: '2',
sku: 'B',
name: 'B',
@ -202,7 +138,7 @@ void main() {
test('load() aggregates data from all three repositories', () async {
final inventoryItems = [
_inv(
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',

View File

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

View File

@ -1,12 +1,6 @@
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/domain/inventory_adjustment_result.dart';
export 'src/domain/inventory_item.dart';
export 'src/domain/inventory_repository.dart';
export 'src/domain/inventory_status.dart';

View File

@ -1,14 +0,0 @@
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

@ -1,11 +0,0 @@
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,34 +1,18 @@
import 'package:flutter/foundation.dart';
import '../domain/inventory_adjustment_result.dart';
import '../domain/inventory_item.dart';
import '../domain/inventory_status.dart';
import 'adjust_inventory_quantity.dart';
import 'create_inventory_item.dart';
import 'get_inventory_items.dart';
import 'update_inventory_item.dart';
/// Controller that manages the inventory workspace state, including
/// filtering by status, free-text search, item selection, and write actions.
/// filtering by status, free-text search, and item selection.
class InventoryController extends ChangeNotifier {
final GetInventoryItems _getInventoryItems;
final AdjustInventoryQuantity _adjustInventoryQuantity;
final CreateInventoryItem _createInventoryItem;
final UpdateInventoryItem _updateInventoryItem;
InventoryController(
this._getInventoryItems,
this._adjustInventoryQuantity,
this._createInventoryItem,
this._updateInventoryItem,
);
InventoryController(this._getInventoryItems);
bool isLoading = false;
bool isUpdating = false;
Object? error;
/// The last write action result, or `null` if no write has been performed.
InventoryAdjustmentResult? lastActionResult;
/// All items returned by the repository (unfiltered).
List<InventoryItem> _allItems = [];
@ -94,83 +78,7 @@ class InventoryController extends ChangeNotifier {
return false;
}
// 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();
}
// Private helpers
void _applyFilters() {
var result = _allItems;

View File

@ -1,11 +0,0 @@
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,191 +1,61 @@
import '../domain/inventory_adjustment_result.dart';
import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart';
import '../domain/inventory_status.dart';
class FakeInventoryRepository implements InventoryRepository {
/// Mutable in-memory store seeded on construction.
final List<InventoryItem> _items = _buildSeedData();
@override
Future<List<InventoryItem>> getInventoryItems() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
static List<InventoryItem> _buildSeedData() {
final now = DateTime(2026, 7, 1);
return [
return const [
InventoryItem(
id: '1',
sku: 'BC-FLR-001',
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,
unitPrice: 12.99,
reorderPoint: 5,
supplier: 'Kell Creations',
location: 'Shelf A1',
status: InventoryStatus.inStock,
lastUpdated: now,
),
InventoryItem(
id: '2',
sku: 'CS-CIT-002',
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,
unitPrice: 16.50,
reorderPoint: 10,
supplier: 'Kell Creations',
location: 'Shelf B2',
status: InventoryStatus.lowStock,
lastUpdated: now,
),
InventoryItem(
id: '3',
sku: 'NL-OCN-003',
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,
unitPrice: 19.99,
reorderPoint: 3,
supplier: null,
location: 'Shelf C1',
status: InventoryStatus.outOfStock,
lastUpdated: now,
),
InventoryItem(
id: '4',
sku: 'JG-BLU-004',
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,
unitPrice: 8.50,
reorderPoint: 8,
supplier: 'Kell Creations',
location: 'Shelf A2',
status: InventoryStatus.inStock,
lastUpdated: now,
),
InventoryItem(
id: '5',
sku: 'SH-SUN-005',
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,
unitPrice: 10.99,
reorderPoint: 8,
supplier: 'Kell Creations',
location: null,
status: InventoryStatus.lowStock,
lastUpdated: now,
),
InventoryItem(
id: '6',
sku: 'SC-SUB-006',
name: 'Sublimated Slate Coaster',
description: 'Single sublimated slate coaster — prototype/draft item.',
imageUrl: '',
category: 'Home Decor',
quantityOnHand: 0,
unitPrice: 14.99,
reorderPoint: 5,
supplier: null,
location: null,
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

@ -1,23 +0,0 @@
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,67 +1,19 @@
import 'inventory_status.dart';
/// A single inventory item tracked by the Kell Creations operations platform.
class InventoryItem {
final String id;
final String sku;
final String name;
final String description;
final String imageUrl;
final String category;
final int quantityOnHand;
final double unitPrice;
final int reorderPoint;
final String? supplier;
final String? location;
final InventoryStatus status;
final DateTime lastUpdated;
const InventoryItem({
required this.id,
required this.sku,
required this.name,
required this.description,
required this.imageUrl,
required this.category,
required this.quantityOnHand,
required this.unitPrice,
required this.reorderPoint,
this.supplier,
this.location,
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,27 +1,5 @@
import 'inventory_adjustment_result.dart';
import 'inventory_item.dart';
abstract class InventoryRepository {
/// Returns all inventory items.
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,11 +1,8 @@
import 'package:design_system/design_system.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/inventory_controller.dart';
import '../application/update_inventory_item.dart';
import '../domain/inventory_repository.dart';
import 'widgets/inventory_item_card.dart';
@ -44,12 +41,7 @@ class _InventoryPageState extends State<InventoryPage> {
@override
void initState() {
super.initState();
controller = InventoryController(
GetInventoryItems(widget.repository),
AdjustInventoryQuantity(widget.repository),
CreateInventoryItem(widget.repository),
UpdateInventoryItem(widget.repository),
);
controller = InventoryController(GetInventoryItems(widget.repository));
// Apply any initial filter / query before loading.
if (widget.initialFilter != null) {

View File

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

View File

@ -1,52 +1,10 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:flutter_test/flutter_test.dart';
// Helpers
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),
);
}
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_inventory/src/application/get_inventory_items.dart';
import 'package:feature_inventory/src/application/inventory_controller.dart';
void main() {
// InventoryStatus
group('InventoryStatus', () {
test('has four values', () {
expect(InventoryStatus.values.length, 4);
@ -60,88 +18,6 @@ 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', () {
late FakeInventoryRepository repository;
@ -161,134 +37,15 @@ void main() {
expect(names, contains('Citrus Coaster Set'));
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', () {
late FakeInventoryRepository repository;
late InventoryController controller;
setUp(() {
repository = FakeInventoryRepository();
controller = _makeController(repository);
controller = InventoryController(GetInventoryItems(repository));
});
tearDown(() {
@ -297,13 +54,11 @@ void main() {
test('starts with empty state', () {
expect(controller.isLoading, false);
expect(controller.isUpdating, false);
expect(controller.items, isEmpty);
expect(controller.selectedItem, isNull);
expect(controller.activeFilter, isNull);
expect(controller.searchQuery, '');
expect(controller.error, isNull);
expect(controller.lastActionResult, isNull);
});
test('load populates items', () async {
@ -400,85 +155,5 @@ void main() {
controller.setFilter('lowStock');
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);
});
});
}