Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1
|
|
@ -20,24 +20,27 @@ abstract final class AppNavigation {
|
||||||
// ── Dashboard → feature handoffs ──────────────────────────────────────
|
// ── Dashboard → feature handoffs ──────────────────────────────────────
|
||||||
|
|
||||||
/// Dashboard KPI "Total Products" / "In Stock" → Inventory page.
|
/// Dashboard KPI "Total Products" / "In Stock" → Inventory page.
|
||||||
static void dashboardToInventory(BuildContext context, {String? filter}) {
|
static void dashboardToInventory(BuildContext context, {String? filter, String? query}) {
|
||||||
navigateTo(
|
navigateTo(
|
||||||
context,
|
context,
|
||||||
NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter}),
|
NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter, 'query': ?query}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dashboard KPI "Draft" → Products page.
|
/// Dashboard KPI "Draft" → Products page.
|
||||||
static void dashboardToProducts(BuildContext context, {String? filter}) {
|
static void dashboardToProducts(BuildContext context, {String? filter, String? query}) {
|
||||||
navigateTo(
|
navigateTo(
|
||||||
context,
|
context,
|
||||||
NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter}),
|
NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter, 'query': ?query}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dashboard KPI "Total Orders" / "Pending" / "Active" → Orders page.
|
/// Dashboard KPI "Total Orders" / "Pending" / "Active" → Orders page.
|
||||||
static void dashboardToOrders(BuildContext context, {String? filter}) {
|
static void dashboardToOrders(BuildContext context, {String? filter, String? query}) {
|
||||||
navigateTo(context, NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter}));
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter, 'query': ?query}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dashboard KPI "Revenue" → Finance page.
|
/// Dashboard KPI "Revenue" → Finance page.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ class NavigationTarget {
|
||||||
/// Convenience: a category filter, if any.
|
/// Convenience: a category filter, if any.
|
||||||
String? get category => arguments['category'];
|
String? get category => arguments['category'];
|
||||||
|
|
||||||
|
/// Convenience: a search query, if any.
|
||||||
|
String? get query => arguments['query'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ abstract final class AppRoutes {
|
||||||
static const String integrations = '/integrations';
|
static const String integrations = '/integrations';
|
||||||
|
|
||||||
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
|
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
|
||||||
|
// Extract navigation arguments passed via NavigationTarget.
|
||||||
|
final args = _extractArgs(settings);
|
||||||
|
|
||||||
switch (settings.name) {
|
switch (settings.name) {
|
||||||
case dashboard:
|
case dashboard:
|
||||||
return _buildRoute(settings, (context) {
|
return _buildRoute(settings, (context) {
|
||||||
|
|
@ -49,6 +52,9 @@ abstract final class AppRoutes {
|
||||||
child: InventoryPage(
|
child: InventoryPage(
|
||||||
repository: AppScope.of(context).inventoryRepository,
|
repository: AppScope.of(context).inventoryRepository,
|
||||||
onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku),
|
onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku),
|
||||||
|
initialFilter: args['filter'],
|
||||||
|
initialQuery: args['query'],
|
||||||
|
initialSelectedSku: args['selectedSku'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -61,6 +67,9 @@ abstract final class AppRoutes {
|
||||||
child: ProductPublishingPage(
|
child: ProductPublishingPage(
|
||||||
repository: AppScope.of(context).productPublishingRepository,
|
repository: AppScope.of(context).productPublishingRepository,
|
||||||
onViewPolicy: () => AppNavigation.productToPolicy(context),
|
onViewPolicy: () => AppNavigation.productToPolicy(context),
|
||||||
|
initialFilter: args['filter'],
|
||||||
|
initialQuery: args['query'],
|
||||||
|
initialSelectedSku: args['selectedSku'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -74,6 +83,9 @@ abstract final class AppRoutes {
|
||||||
repository: AppScope.of(context).ordersRepository,
|
repository: AppScope.of(context).ordersRepository,
|
||||||
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
|
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
|
||||||
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
|
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
|
||||||
|
initialFilter: args['filter'],
|
||||||
|
initialQuery: args['query'],
|
||||||
|
initialSelectedId: args['selectedId'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -96,6 +108,9 @@ abstract final class AppRoutes {
|
||||||
repository: AppScope.of(context).policyRepository,
|
repository: AppScope.of(context).policyRepository,
|
||||||
onViewRelatedPage: (category) =>
|
onViewRelatedPage: (category) =>
|
||||||
AppNavigation.policyToRelatedPage(context, category: category),
|
AppNavigation.policyToRelatedPage(context, category: category),
|
||||||
|
initialCategory: args['category'],
|
||||||
|
initialQuery: args['query'],
|
||||||
|
initialSelectedId: args['selectedId'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -129,4 +144,14 @@ abstract final class AppRoutes {
|
||||||
) {
|
) {
|
||||||
return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder);
|
return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Safely extracts the `Map<String, String>` arguments from [RouteSettings].
|
||||||
|
///
|
||||||
|
/// Returns an empty map when no arguments are present or the type doesn't
|
||||||
|
/// match, so callers can always index safely.
|
||||||
|
static Map<String, String> _extractArgs(RouteSettings settings) {
|
||||||
|
final raw = settings.arguments;
|
||||||
|
if (raw is Map<String, String>) return raw;
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,14 @@ void main() {
|
||||||
'selectedId': 'ID-001',
|
'selectedId': 'ID-001',
|
||||||
'filter': 'lowStock',
|
'filter': 'lowStock',
|
||||||
'category': 'Product Compliance',
|
'category': 'Product Compliance',
|
||||||
|
'query': 'coaster',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(target.selectedSku, 'SKU-001');
|
expect(target.selectedSku, 'SKU-001');
|
||||||
expect(target.selectedId, 'ID-001');
|
expect(target.selectedId, 'ID-001');
|
||||||
expect(target.filter, 'lowStock');
|
expect(target.filter, 'lowStock');
|
||||||
expect(target.category, 'Product Compliance');
|
expect(target.category, 'Product Compliance');
|
||||||
|
expect(target.query, 'coaster');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('convenience getters return null when key is absent', () {
|
test('convenience getters return null when key is absent', () {
|
||||||
|
|
@ -37,6 +39,7 @@ void main() {
|
||||||
expect(target.selectedId, isNull);
|
expect(target.selectedId, isNull);
|
||||||
expect(target.filter, isNull);
|
expect(target.filter, isNull);
|
||||||
expect(target.category, isNull);
|
expect(target.category, isNull);
|
||||||
|
expect(target.query, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('equality works for same route and arguments', () {
|
test('equality works for same route and arguments', () {
|
||||||
|
|
@ -63,5 +66,10 @@ void main() {
|
||||||
expect(target.toString(), contains('/inventory'));
|
expect(target.toString(), contains('/inventory'));
|
||||||
expect(target.toString(), contains('lowStock'));
|
expect(target.toString(), contains('lowStock'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('query argument round-trips through convenience getter', () {
|
||||||
|
const target = NavigationTarget(route: '/inventory', arguments: {'query': 'bowl cozy'});
|
||||||
|
expect(target.query, 'bowl cozy');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,44 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
|
import '../domain/inventory_status.dart';
|
||||||
import 'get_inventory_items.dart';
|
import 'get_inventory_items.dart';
|
||||||
|
|
||||||
|
/// Controller that manages the inventory workspace state, including
|
||||||
|
/// filtering by status, free-text search, and item selection.
|
||||||
class InventoryController extends ChangeNotifier {
|
class InventoryController extends ChangeNotifier {
|
||||||
final GetInventoryItems _getInventoryItems;
|
final GetInventoryItems _getInventoryItems;
|
||||||
|
|
||||||
InventoryController(this._getInventoryItems);
|
InventoryController(this._getInventoryItems);
|
||||||
|
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
List<InventoryItem> items = [];
|
|
||||||
Object? error;
|
Object? error;
|
||||||
|
|
||||||
|
/// All items returned by the repository (unfiltered).
|
||||||
|
List<InventoryItem> _allItems = [];
|
||||||
|
|
||||||
|
/// The currently visible items after applying [activeFilter] and [searchQuery].
|
||||||
|
List<InventoryItem> items = [];
|
||||||
|
|
||||||
|
/// The currently selected item, if any.
|
||||||
|
InventoryItem? selectedItem;
|
||||||
|
|
||||||
|
/// The active status filter label, or `null` for "all".
|
||||||
|
///
|
||||||
|
/// Recognised values: `'inStock'`, `'lowStock'`, `'outOfStock'`, `'draft'`.
|
||||||
|
String? activeFilter;
|
||||||
|
|
||||||
|
/// The current free-text search query applied to name / SKU.
|
||||||
|
String searchQuery = '';
|
||||||
|
|
||||||
|
/// Loads all inventory items and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
items = await _getInventoryItems();
|
_allItems = await _getInventoryItems();
|
||||||
|
_applyFilters();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e;
|
error = e;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -25,4 +46,78 @@ class InventoryController extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the status filter and recomputes the visible list.
|
||||||
|
void setFilter(String? filter) {
|
||||||
|
activeFilter = filter;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the search query and recomputes the visible list.
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
searchQuery = query;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects an item for detail view / highlight.
|
||||||
|
void selectItem(InventoryItem item) {
|
||||||
|
selectedItem = item;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to select an item by SKU. Returns `true` if found.
|
||||||
|
bool selectBySku(String sku) {
|
||||||
|
final match = _allItems.where((i) => i.sku == sku).firstOrNull;
|
||||||
|
if (match != null) {
|
||||||
|
selectedItem = match;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _applyFilters() {
|
||||||
|
var result = _allItems;
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
final status = _parseStatus(activeFilter);
|
||||||
|
if (status != null) {
|
||||||
|
result = result.where((i) => i.status == status).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-text search on name and SKU
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
final q = searchQuery.toLowerCase();
|
||||||
|
result = result.where((i) {
|
||||||
|
return i.name.toLowerCase().contains(q) || i.sku.toLowerCase().contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
items = result;
|
||||||
|
|
||||||
|
// Keep selection valid; clear if the selected item is no longer visible.
|
||||||
|
if (selectedItem != null && !items.contains(selectedItem)) {
|
||||||
|
selectedItem = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static InventoryStatus? _parseStatus(String? filter) {
|
||||||
|
if (filter == null) return null;
|
||||||
|
switch (filter) {
|
||||||
|
case 'inStock':
|
||||||
|
return InventoryStatus.inStock;
|
||||||
|
case 'lowStock':
|
||||||
|
return InventoryStatus.lowStock;
|
||||||
|
case 'outOfStock':
|
||||||
|
return InventoryStatus.outOfStock;
|
||||||
|
case 'draft':
|
||||||
|
return InventoryStatus.draft;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,23 @@ class InventoryPage extends StatefulWidget {
|
||||||
/// Provided by the app layer to enable cross-feature handoffs.
|
/// Provided by the app layer to enable cross-feature handoffs.
|
||||||
final void Function(String sku)? onViewProduct;
|
final void Function(String sku)? onViewProduct;
|
||||||
|
|
||||||
const InventoryPage({super.key, required this.repository, this.onViewProduct});
|
/// Optional initial status filter to apply on first load (e.g. `'lowStock'`).
|
||||||
|
final String? initialFilter;
|
||||||
|
|
||||||
|
/// Optional initial search query to apply on first load.
|
||||||
|
final String? initialQuery;
|
||||||
|
|
||||||
|
/// Optional SKU to pre-select on first load (from a navigation handoff).
|
||||||
|
final String? initialSelectedSku;
|
||||||
|
|
||||||
|
const InventoryPage({
|
||||||
|
super.key,
|
||||||
|
required this.repository,
|
||||||
|
this.onViewProduct,
|
||||||
|
this.initialFilter,
|
||||||
|
this.initialQuery,
|
||||||
|
this.initialSelectedSku,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InventoryPage> createState() => _InventoryPageState();
|
State<InventoryPage> createState() => _InventoryPageState();
|
||||||
|
|
@ -26,7 +42,21 @@ class _InventoryPageState extends State<InventoryPage> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = InventoryController(GetInventoryItems(widget.repository));
|
controller = InventoryController(GetInventoryItems(widget.repository));
|
||||||
controller.load();
|
|
||||||
|
// Apply any initial filter / query before loading.
|
||||||
|
if (widget.initialFilter != null) {
|
||||||
|
controller.activeFilter = widget.initialFilter;
|
||||||
|
}
|
||||||
|
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
||||||
|
controller.searchQuery = widget.initialQuery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.load().then((_) {
|
||||||
|
// After data is loaded, try to pre-select by SKU if requested.
|
||||||
|
if (widget.initialSelectedSku != null) {
|
||||||
|
controller.selectBySku(widget.initialSelectedSku!);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
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() {
|
void main() {
|
||||||
group('InventoryStatus', () {
|
group('InventoryStatus', () {
|
||||||
|
|
@ -36,4 +38,122 @@ void main() {
|
||||||
expect(names, contains('Ocean Nightlight'));
|
expect(names, contains('Ocean Nightlight'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('InventoryController', () {
|
||||||
|
late FakeInventoryRepository repository;
|
||||||
|
late InventoryController controller;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakeInventoryRepository();
|
||||||
|
controller = InventoryController(GetInventoryItems(repository));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts with empty state', () {
|
||||||
|
expect(controller.isLoading, false);
|
||||||
|
expect(controller.items, isEmpty);
|
||||||
|
expect(controller.selectedItem, isNull);
|
||||||
|
expect(controller.activeFilter, isNull);
|
||||||
|
expect(controller.searchQuery, '');
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load populates items', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.isLoading, false);
|
||||||
|
expect(controller.items.length, 6);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setFilter filters by status', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('lowStock');
|
||||||
|
expect(controller.items.length, 2);
|
||||||
|
expect(controller.items.every((i) => i.status == InventoryStatus.lowStock), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setFilter with null shows all items', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('lowStock');
|
||||||
|
expect(controller.items.length, 2);
|
||||||
|
|
||||||
|
controller.setFilter(null);
|
||||||
|
expect(controller.items.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by name', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('cozy');
|
||||||
|
expect(controller.items.length, 1);
|
||||||
|
expect(controller.items.first.name, 'Floral Bowl Cozy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by SKU', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('BC-FLR');
|
||||||
|
expect(controller.items.length, 1);
|
||||||
|
expect(controller.items.first.sku, 'BC-FLR-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search is case-insensitive', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('OCEAN');
|
||||||
|
expect(controller.items.length, 1);
|
||||||
|
expect(controller.items.first.name, 'Ocean Nightlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter and search combine', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('inStock');
|
||||||
|
controller.setSearchQuery('floral');
|
||||||
|
expect(controller.items.length, 1);
|
||||||
|
expect(controller.items.first.name, 'Floral Bowl Cozy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectItem sets selectedItem', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final item = controller.items[2];
|
||||||
|
controller.selectItem(item);
|
||||||
|
expect(controller.selectedItem, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectBySku selects matching item', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectBySku('CS-CIT-002');
|
||||||
|
expect(found, true);
|
||||||
|
expect(controller.selectedItem!.sku, 'CS-CIT-002');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectBySku returns false for unknown SKU', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectBySku('UNKNOWN');
|
||||||
|
expect(found, false);
|
||||||
|
expect(controller.selectedItem, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection is cleared when filtered out', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select an inStock item.
|
||||||
|
controller.selectBySku('BC-FLR-001');
|
||||||
|
expect(controller.selectedItem, isNotNull);
|
||||||
|
|
||||||
|
// Filter to lowStock — the selected item should be cleared.
|
||||||
|
controller.setFilter('lowStock');
|
||||||
|
expect(controller.selectedItem, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,27 +1,46 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../domain/order.dart';
|
import '../domain/order.dart';
|
||||||
|
import '../domain/order_status.dart';
|
||||||
import 'get_orders.dart';
|
import 'get_orders.dart';
|
||||||
|
|
||||||
/// Controller that manages the orders workspace state.
|
/// Controller that manages the orders workspace state, including
|
||||||
|
/// filtering by order status, free-text search, and order selection.
|
||||||
class OrdersController extends ChangeNotifier {
|
class OrdersController extends ChangeNotifier {
|
||||||
final GetOrders _getOrders;
|
final GetOrders _getOrders;
|
||||||
|
|
||||||
OrdersController(this._getOrders);
|
OrdersController(this._getOrders);
|
||||||
|
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
List<Order> orders = [];
|
|
||||||
Order? selectedOrder;
|
|
||||||
Object? error;
|
Object? error;
|
||||||
|
|
||||||
/// Loads all orders.
|
/// All orders returned by the repository (unfiltered).
|
||||||
|
List<Order> _allOrders = [];
|
||||||
|
|
||||||
|
/// The currently visible orders after applying [activeFilter] and [searchQuery].
|
||||||
|
List<Order> orders = [];
|
||||||
|
|
||||||
|
/// The currently selected order for detail view.
|
||||||
|
Order? selectedOrder;
|
||||||
|
|
||||||
|
/// The active status filter label, or `null` for "all".
|
||||||
|
///
|
||||||
|
/// Recognised values: `'pending'`, `'processing'`, `'shipped'`,
|
||||||
|
/// `'delivered'`, `'cancelled'`.
|
||||||
|
String? activeFilter;
|
||||||
|
|
||||||
|
/// The current free-text search query applied to customer name / order ID.
|
||||||
|
String searchQuery = '';
|
||||||
|
|
||||||
|
/// Loads all orders and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orders = await _getOrders();
|
_allOrders = await _getOrders();
|
||||||
|
_applyFilters();
|
||||||
// Auto-select the first order if nothing is selected.
|
// Auto-select the first order if nothing is selected.
|
||||||
selectedOrder ??= orders.isNotEmpty ? orders.first : null;
|
selectedOrder ??= orders.isNotEmpty ? orders.first : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -32,9 +51,79 @@ class OrdersController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the status filter and recomputes the visible list.
|
||||||
|
void setFilter(String? filter) {
|
||||||
|
activeFilter = filter;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the search query and recomputes the visible list.
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
searchQuery = query;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects an order for detail view.
|
/// Selects an order for detail view.
|
||||||
void selectOrder(Order order) {
|
void selectOrder(Order order) {
|
||||||
selectedOrder = order;
|
selectedOrder = order;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to select an order by ID. Returns `true` if found.
|
||||||
|
bool selectById(String id) {
|
||||||
|
final match = _allOrders.where((o) => o.id == id).firstOrNull;
|
||||||
|
if (match != null) {
|
||||||
|
selectedOrder = match;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _applyFilters() {
|
||||||
|
var result = _allOrders;
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
final status = _parseStatus(activeFilter);
|
||||||
|
if (status != null) {
|
||||||
|
result = result.where((o) => o.status == status).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-text search on customer name and order ID
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
final q = searchQuery.toLowerCase();
|
||||||
|
result = result.where((o) {
|
||||||
|
return o.customerName.toLowerCase().contains(q) || o.id.toLowerCase().contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
orders = result;
|
||||||
|
|
||||||
|
// Keep selection valid; clear if the selected order is no longer visible.
|
||||||
|
if (selectedOrder != null && !orders.any((o) => o.id == selectedOrder!.id)) {
|
||||||
|
selectedOrder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static OrderStatus? _parseStatus(String? filter) {
|
||||||
|
if (filter == null) return null;
|
||||||
|
switch (filter) {
|
||||||
|
case 'pending':
|
||||||
|
return OrderStatus.pending;
|
||||||
|
case 'processing':
|
||||||
|
return OrderStatus.processing;
|
||||||
|
case 'shipped':
|
||||||
|
return OrderStatus.shipped;
|
||||||
|
case 'delivered':
|
||||||
|
return OrderStatus.delivered;
|
||||||
|
case 'cancelled':
|
||||||
|
return OrderStatus.cancelled;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,24 @@ class OrdersPage extends StatefulWidget {
|
||||||
/// Optional callback to navigate to the Inventory page for a given SKU.
|
/// Optional callback to navigate to the Inventory page for a given SKU.
|
||||||
final void Function(String sku)? onViewInventory;
|
final void Function(String sku)? onViewInventory;
|
||||||
|
|
||||||
const OrdersPage({super.key, required this.repository, this.onViewProduct, this.onViewInventory});
|
/// Optional initial status filter to apply on first load (e.g. `'pending'`).
|
||||||
|
final String? initialFilter;
|
||||||
|
|
||||||
|
/// Optional initial search query to apply on first load.
|
||||||
|
final String? initialQuery;
|
||||||
|
|
||||||
|
/// Optional order ID to pre-select on first load (from a navigation handoff).
|
||||||
|
final String? initialSelectedId;
|
||||||
|
|
||||||
|
const OrdersPage({
|
||||||
|
super.key,
|
||||||
|
required this.repository,
|
||||||
|
this.onViewProduct,
|
||||||
|
this.onViewInventory,
|
||||||
|
this.initialFilter,
|
||||||
|
this.initialQuery,
|
||||||
|
this.initialSelectedId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OrdersPage> createState() => _OrdersPageState();
|
State<OrdersPage> createState() => _OrdersPageState();
|
||||||
|
|
@ -33,7 +50,21 @@ class _OrdersPageState extends State<OrdersPage> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = OrdersController(GetOrders(widget.repository));
|
controller = OrdersController(GetOrders(widget.repository));
|
||||||
controller.load();
|
|
||||||
|
// Apply any initial filter / query before loading.
|
||||||
|
if (widget.initialFilter != null) {
|
||||||
|
controller.activeFilter = widget.initialFilter;
|
||||||
|
}
|
||||||
|
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
||||||
|
controller.searchQuery = widget.initialQuery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.load().then((_) {
|
||||||
|
// After data is loaded, try to pre-select by ID if requested.
|
||||||
|
if (widget.initialSelectedId != null) {
|
||||||
|
controller.selectById(widget.initialSelectedId!);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ void main() {
|
||||||
expect(controller.isLoading, false);
|
expect(controller.isLoading, false);
|
||||||
expect(controller.orders, isEmpty);
|
expect(controller.orders, isEmpty);
|
||||||
expect(controller.selectedOrder, isNull);
|
expect(controller.selectedOrder, isNull);
|
||||||
|
expect(controller.activeFilter, isNull);
|
||||||
|
expect(controller.searchQuery, '');
|
||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,5 +45,75 @@ void main() {
|
||||||
|
|
||||||
expect(controller.selectedOrder!.id, third.id);
|
expect(controller.selectedOrder!.id, third.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setFilter filters by order status', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('pending');
|
||||||
|
expect(controller.orders.length, 2);
|
||||||
|
expect(controller.orders.every((o) => o.status == OrderStatus.pending), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setFilter with null shows all orders', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('cancelled');
|
||||||
|
expect(controller.orders.length, 1);
|
||||||
|
|
||||||
|
controller.setFilter(null);
|
||||||
|
expect(controller.orders.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by customer name', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('sarah');
|
||||||
|
expect(controller.orders.length, 1);
|
||||||
|
expect(controller.orders.first.customerName, 'Sarah Mitchell');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by order ID', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('KC-1003');
|
||||||
|
expect(controller.orders.length, 1);
|
||||||
|
expect(controller.orders.first.id, 'KC-1003');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter and search combine', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('pending');
|
||||||
|
controller.setSearchQuery('david');
|
||||||
|
expect(controller.orders.length, 1);
|
||||||
|
expect(controller.orders.first.customerName, 'David Park');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectById selects matching order', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectById('KC-1004');
|
||||||
|
expect(found, true);
|
||||||
|
expect(controller.selectedOrder!.id, 'KC-1004');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectById returns false for unknown ID', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectById('UNKNOWN');
|
||||||
|
expect(found, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection is cleared when filtered out', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select a delivered order.
|
||||||
|
controller.selectById('KC-1001');
|
||||||
|
expect(controller.selectedOrder, isNotNull);
|
||||||
|
|
||||||
|
// Filter to pending — the selected order should be cleared.
|
||||||
|
controller.setFilter('pending');
|
||||||
|
expect(controller.selectedOrder, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,43 @@ import 'package:flutter/foundation.dart';
|
||||||
import '../domain/policy_check_result.dart';
|
import '../domain/policy_check_result.dart';
|
||||||
import 'get_policy_checks.dart';
|
import 'get_policy_checks.dart';
|
||||||
|
|
||||||
/// Controller that manages the policy-checks workspace state.
|
/// Controller that manages the policy-checks workspace state, including
|
||||||
|
/// filtering by category, free-text search, and check selection.
|
||||||
class PolicyController extends ChangeNotifier {
|
class PolicyController extends ChangeNotifier {
|
||||||
final GetPolicyChecks _getPolicyChecks;
|
final GetPolicyChecks _getPolicyChecks;
|
||||||
|
|
||||||
PolicyController(this._getPolicyChecks);
|
PolicyController(this._getPolicyChecks);
|
||||||
|
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
List<PolicyCheckResult> checks = [];
|
|
||||||
PolicyCheckResult? selectedCheck;
|
|
||||||
Object? error;
|
Object? error;
|
||||||
|
|
||||||
/// Loads all policy checks.
|
/// All checks returned by the repository (unfiltered).
|
||||||
|
List<PolicyCheckResult> _allChecks = [];
|
||||||
|
|
||||||
|
/// The currently visible checks after applying [activeCategory] and [searchQuery].
|
||||||
|
List<PolicyCheckResult> checks = [];
|
||||||
|
|
||||||
|
/// The currently selected check for detail view.
|
||||||
|
PolicyCheckResult? selectedCheck;
|
||||||
|
|
||||||
|
/// The active category filter, or `null` for "all".
|
||||||
|
///
|
||||||
|
/// Matches [PolicyCheckResult.category] values such as
|
||||||
|
/// `'Product Compliance'`, `'Inventory Governance'`, etc.
|
||||||
|
String? activeCategory;
|
||||||
|
|
||||||
|
/// The current free-text search query applied to title / summary.
|
||||||
|
String searchQuery = '';
|
||||||
|
|
||||||
|
/// Loads all policy checks and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checks = await _getPolicyChecks();
|
_allChecks = await _getPolicyChecks();
|
||||||
|
_applyFilters();
|
||||||
// Auto-select the first check if nothing is selected.
|
// Auto-select the first check if nothing is selected.
|
||||||
selectedCheck ??= checks.isNotEmpty ? checks.first : null;
|
selectedCheck ??= checks.isNotEmpty ? checks.first : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -32,9 +50,60 @@ class PolicyController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the category filter and recomputes the visible list.
|
||||||
|
void setCategory(String? category) {
|
||||||
|
activeCategory = category;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the search query and recomputes the visible list.
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
searchQuery = query;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects a policy check for detail view.
|
/// Selects a policy check for detail view.
|
||||||
void selectCheck(PolicyCheckResult check) {
|
void selectCheck(PolicyCheckResult check) {
|
||||||
selectedCheck = check;
|
selectedCheck = check;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to select a check by ID. Returns `true` if found.
|
||||||
|
bool selectById(String id) {
|
||||||
|
final match = _allChecks.where((c) => c.id == id).firstOrNull;
|
||||||
|
if (match != null) {
|
||||||
|
selectedCheck = match;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _applyFilters() {
|
||||||
|
var result = _allChecks;
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (activeCategory != null && activeCategory!.isNotEmpty) {
|
||||||
|
result = result.where((c) => c.category == activeCategory).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-text search on title and summary
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
final q = searchQuery.toLowerCase();
|
||||||
|
result = result.where((c) {
|
||||||
|
return c.title.toLowerCase().contains(q) || c.summary.toLowerCase().contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
checks = result;
|
||||||
|
|
||||||
|
// Keep selection valid; clear if the selected check is no longer visible.
|
||||||
|
if (selectedCheck != null && !checks.any((c) => c.id == selectedCheck!.id)) {
|
||||||
|
selectedCheck = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,24 @@ class PolicyPage extends StatefulWidget {
|
||||||
/// the policy check's category. Provided by the app layer.
|
/// the policy check's category. Provided by the app layer.
|
||||||
final void Function(String category)? onViewRelatedPage;
|
final void Function(String category)? onViewRelatedPage;
|
||||||
|
|
||||||
const PolicyPage({super.key, required this.repository, this.onViewRelatedPage});
|
/// Optional initial category filter to apply on first load
|
||||||
|
/// (e.g. `'Product Compliance'`).
|
||||||
|
final String? initialCategory;
|
||||||
|
|
||||||
|
/// Optional initial search query to apply on first load.
|
||||||
|
final String? initialQuery;
|
||||||
|
|
||||||
|
/// Optional check ID to pre-select on first load (from a navigation handoff).
|
||||||
|
final String? initialSelectedId;
|
||||||
|
|
||||||
|
const PolicyPage({
|
||||||
|
super.key,
|
||||||
|
required this.repository,
|
||||||
|
this.onViewRelatedPage,
|
||||||
|
this.initialCategory,
|
||||||
|
this.initialQuery,
|
||||||
|
this.initialSelectedId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PolicyPage> createState() => _PolicyPageState();
|
State<PolicyPage> createState() => _PolicyPageState();
|
||||||
|
|
@ -31,7 +48,21 @@ class _PolicyPageState extends State<PolicyPage> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = PolicyController(GetPolicyChecks(widget.repository));
|
controller = PolicyController(GetPolicyChecks(widget.repository));
|
||||||
controller.load();
|
|
||||||
|
// Apply any initial category / query before loading.
|
||||||
|
if (widget.initialCategory != null) {
|
||||||
|
controller.activeCategory = widget.initialCategory;
|
||||||
|
}
|
||||||
|
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
||||||
|
controller.searchQuery = widget.initialQuery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.load().then((_) {
|
||||||
|
// After data is loaded, try to pre-select by ID if requested.
|
||||||
|
if (widget.initialSelectedId != null) {
|
||||||
|
controller.selectById(widget.initialSelectedId!);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ void main() {
|
||||||
expect(controller.isLoading, false);
|
expect(controller.isLoading, false);
|
||||||
expect(controller.checks, isEmpty);
|
expect(controller.checks, isEmpty);
|
||||||
expect(controller.selectedCheck, isNull);
|
expect(controller.selectedCheck, isNull);
|
||||||
|
expect(controller.activeCategory, isNull);
|
||||||
|
expect(controller.searchQuery, '');
|
||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,5 +103,75 @@ void main() {
|
||||||
|
|
||||||
expect(controller.selectedCheck!.id, third.id);
|
expect(controller.selectedCheck!.id, third.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setCategory filters by category', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setCategory('Product Compliance');
|
||||||
|
expect(controller.checks.length, 2);
|
||||||
|
expect(controller.checks.every((c) => c.category == 'Product Compliance'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCategory with null shows all checks', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setCategory('Order Operations');
|
||||||
|
expect(controller.checks.length, 2);
|
||||||
|
|
||||||
|
controller.setCategory(null);
|
||||||
|
expect(controller.checks.length, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by title', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('pricing');
|
||||||
|
expect(controller.checks.length, 1);
|
||||||
|
expect(controller.checks.first.title, 'Pricing Consistency');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by summary', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('reorder threshold');
|
||||||
|
expect(controller.checks.length, 1);
|
||||||
|
expect(controller.checks.first.id, 'POL-006');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('category and search combine', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setCategory('Inventory Governance');
|
||||||
|
controller.setSearchQuery('accuracy');
|
||||||
|
expect(controller.checks.length, 1);
|
||||||
|
expect(controller.checks.first.id, 'POL-002');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectById selects matching check', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectById('POL-005');
|
||||||
|
expect(found, true);
|
||||||
|
expect(controller.selectedCheck!.id, 'POL-005');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectById returns false for unknown ID', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectById('UNKNOWN');
|
||||||
|
expect(found, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection is cleared when filtered out', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select a Product Compliance check.
|
||||||
|
controller.selectById('POL-001');
|
||||||
|
expect(controller.selectedCheck, isNotNull);
|
||||||
|
|
||||||
|
// Filter to Order Operations — the selected check should be cleared.
|
||||||
|
controller.setCategory('Order Operations');
|
||||||
|
expect(controller.selectedCheck, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../domain/product_draft.dart';
|
import '../domain/product_draft.dart';
|
||||||
|
import '../domain/publish_status.dart';
|
||||||
import 'get_product_drafts.dart';
|
import 'get_product_drafts.dart';
|
||||||
import 'publish_product.dart';
|
import 'publish_product.dart';
|
||||||
|
|
||||||
/// Controller that manages the product publishing workspace state.
|
/// Controller that manages the product publishing workspace state, including
|
||||||
|
/// filtering by publish status, free-text search, and draft selection.
|
||||||
class ProductPublishingController extends ChangeNotifier {
|
class ProductPublishingController extends ChangeNotifier {
|
||||||
final GetProductDrafts _getProductDrafts;
|
final GetProductDrafts _getProductDrafts;
|
||||||
final PublishProduct _publishProduct;
|
final PublishProduct _publishProduct;
|
||||||
|
|
@ -12,18 +14,34 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
ProductPublishingController(this._getProductDrafts, this._publishProduct);
|
ProductPublishingController(this._getProductDrafts, this._publishProduct);
|
||||||
|
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
List<ProductDraft> drafts = [];
|
|
||||||
ProductDraft? selectedDraft;
|
|
||||||
Object? error;
|
Object? error;
|
||||||
|
|
||||||
/// Loads all product drafts.
|
/// All drafts returned by the repository (unfiltered).
|
||||||
|
List<ProductDraft> _allDrafts = [];
|
||||||
|
|
||||||
|
/// The currently visible drafts after applying [activeFilter] and [searchQuery].
|
||||||
|
List<ProductDraft> drafts = [];
|
||||||
|
|
||||||
|
/// The currently selected draft for preview.
|
||||||
|
ProductDraft? selectedDraft;
|
||||||
|
|
||||||
|
/// The active status filter label, or `null` for "all".
|
||||||
|
///
|
||||||
|
/// Recognised values: `'draft'`, `'pendingReview'`, `'published'`, `'unpublished'`.
|
||||||
|
String? activeFilter;
|
||||||
|
|
||||||
|
/// The current free-text search query applied to name / SKU.
|
||||||
|
String searchQuery = '';
|
||||||
|
|
||||||
|
/// Loads all product drafts and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
drafts = await _getProductDrafts();
|
_allDrafts = await _getProductDrafts();
|
||||||
|
_applyFilters();
|
||||||
// Auto-select the first draft if nothing is selected.
|
// Auto-select the first draft if nothing is selected.
|
||||||
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
|
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -34,12 +52,37 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the status filter and recomputes the visible list.
|
||||||
|
void setFilter(String? filter) {
|
||||||
|
activeFilter = filter;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the search query and recomputes the visible list.
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
searchQuery = query;
|
||||||
|
_applyFilters();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects a draft for preview.
|
/// Selects a draft for preview.
|
||||||
void selectDraft(ProductDraft draft) {
|
void selectDraft(ProductDraft draft) {
|
||||||
selectedDraft = draft;
|
selectedDraft = draft;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to select a draft by SKU. Returns `true` if found.
|
||||||
|
bool selectBySku(String sku) {
|
||||||
|
final match = _allDrafts.where((d) => d.sku == sku).firstOrNull;
|
||||||
|
if (match != null) {
|
||||||
|
selectedDraft = match;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Publishes the draft with the given [id] and reloads the list.
|
/// Publishes the draft with the given [id] and reloads the list.
|
||||||
Future<void> publish(String id) async {
|
Future<void> publish(String id) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -50,4 +93,47 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _applyFilters() {
|
||||||
|
var result = _allDrafts;
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
final status = _parseStatus(activeFilter);
|
||||||
|
if (status != null) {
|
||||||
|
result = result.where((d) => d.status == status).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-text search on name and SKU
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
final q = searchQuery.toLowerCase();
|
||||||
|
result = result.where((d) {
|
||||||
|
return d.name.toLowerCase().contains(q) || d.sku.toLowerCase().contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
drafts = result;
|
||||||
|
|
||||||
|
// Keep selection valid; clear if the selected draft is no longer visible.
|
||||||
|
if (selectedDraft != null && !drafts.any((d) => d.id == selectedDraft!.id)) {
|
||||||
|
selectedDraft = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static PublishStatus? _parseStatus(String? filter) {
|
||||||
|
if (filter == null) return null;
|
||||||
|
switch (filter) {
|
||||||
|
case 'draft':
|
||||||
|
return PublishStatus.draft;
|
||||||
|
case 'pendingReview':
|
||||||
|
return PublishStatus.pendingReview;
|
||||||
|
case 'published':
|
||||||
|
return PublishStatus.published;
|
||||||
|
case 'unpublished':
|
||||||
|
return PublishStatus.unpublished;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,23 @@ class ProductPublishingPage extends StatefulWidget {
|
||||||
/// Provided by the app layer to enable cross-feature handoffs.
|
/// Provided by the app layer to enable cross-feature handoffs.
|
||||||
final VoidCallback? onViewPolicy;
|
final VoidCallback? onViewPolicy;
|
||||||
|
|
||||||
const ProductPublishingPage({super.key, required this.repository, this.onViewPolicy});
|
/// Optional initial status filter to apply on first load (e.g. `'draft'`).
|
||||||
|
final String? initialFilter;
|
||||||
|
|
||||||
|
/// Optional initial search query to apply on first load.
|
||||||
|
final String? initialQuery;
|
||||||
|
|
||||||
|
/// Optional SKU to pre-select on first load (from a navigation handoff).
|
||||||
|
final String? initialSelectedSku;
|
||||||
|
|
||||||
|
const ProductPublishingPage({
|
||||||
|
super.key,
|
||||||
|
required this.repository,
|
||||||
|
this.onViewPolicy,
|
||||||
|
this.initialFilter,
|
||||||
|
this.initialQuery,
|
||||||
|
this.initialSelectedSku,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProductPublishingPage> createState() => _ProductPublishingPageState();
|
State<ProductPublishingPage> createState() => _ProductPublishingPageState();
|
||||||
|
|
@ -33,7 +49,21 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
super.initState();
|
super.initState();
|
||||||
final repo = widget.repository;
|
final repo = widget.repository;
|
||||||
controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo));
|
controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo));
|
||||||
controller.load();
|
|
||||||
|
// Apply any initial filter / query before loading.
|
||||||
|
if (widget.initialFilter != null) {
|
||||||
|
controller.activeFilter = widget.initialFilter;
|
||||||
|
}
|
||||||
|
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
||||||
|
controller.searchQuery = widget.initialQuery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.load().then((_) {
|
||||||
|
// After data is loaded, try to pre-select by SKU if requested.
|
||||||
|
if (widget.initialSelectedSku != null) {
|
||||||
|
controller.selectBySku(widget.initialSelectedSku!);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ void main() {
|
||||||
expect(controller.isLoading, false);
|
expect(controller.isLoading, false);
|
||||||
expect(controller.drafts, isEmpty);
|
expect(controller.drafts, isEmpty);
|
||||||
expect(controller.selectedDraft, isNull);
|
expect(controller.selectedDraft, isNull);
|
||||||
|
expect(controller.activeFilter, isNull);
|
||||||
|
expect(controller.searchQuery, '');
|
||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,5 +59,75 @@ void main() {
|
||||||
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
expect(updated.status, PublishStatus.published);
|
expect(updated.status, PublishStatus.published);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setFilter filters by publish status', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
expect(controller.drafts.every((d) => d.status == PublishStatus.draft), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setFilter with null shows all drafts', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('published');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
controller.setFilter(null);
|
||||||
|
expect(controller.drafts.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by name', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('nightlight');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.name, 'Ocean Nightlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSearchQuery filters by SKU', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('JG-BLU');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.sku, 'JG-BLU-004');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter and search combine', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('published');
|
||||||
|
controller.setSearchQuery('floral');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.name, 'Floral Bowl Cozy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectBySku selects matching draft', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectBySku('CS-CIT-002');
|
||||||
|
expect(found, true);
|
||||||
|
expect(controller.selectedDraft!.sku, 'CS-CIT-002');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectBySku returns false for unknown SKU', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final found = controller.selectBySku('UNKNOWN');
|
||||||
|
expect(found, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection is cleared when filtered out', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select a published draft.
|
||||||
|
controller.selectBySku('BC-FLR-001');
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
|
||||||
|
// Filter to draft status — the selected item should be cleared.
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.selectedDraft, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue