From 00a667d19eec9889171ee7fb2b10d518cb7e0bd6 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 4 Apr 2026 14:07:21 -0400 Subject: [PATCH] feat(dashboard): compose live summary data across features --- .../application/dashboard_controller.dart | 34 ++ .../application/get_dashboard_summary.dart | 36 ++ .../dashboard/domain/dashboard_summary.dart | 148 ++++++++ .../kell_web/lib/pages/dashboard_page.dart | 108 ++++-- .../apps/kell_web/lib/routing/app_routes.dart | 22 +- .../dashboard_controller_test.dart | 201 +++++++++++ .../domain/dashboard_summary_test.dart | 326 ++++++++++++++++++ 7 files changed, 849 insertions(+), 26 deletions(-) create mode 100644 kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart create mode 100644 kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart create mode 100644 kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart new file mode 100644 index 0000000..65ea4b8 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; + +import '../domain/dashboard_summary.dart'; +import 'get_dashboard_summary.dart'; + +/// Controller that manages the dashboard summary state. +/// +/// Follows the same [ChangeNotifier] pattern used by other feature +/// controllers (e.g. `OrdersController`). +class DashboardController extends ChangeNotifier { + final GetDashboardSummary _getDashboardSummary; + + DashboardController(this._getDashboardSummary); + + bool isLoading = false; + DashboardSummary summary = DashboardSummary.empty; + Object? error; + + /// Loads the aggregated dashboard summary from all repositories. + Future load() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + summary = await _getDashboardSummary(); + } catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart new file mode 100644 index 0000000..6eb7653 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart @@ -0,0 +1,36 @@ +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; + +import '../domain/dashboard_summary.dart'; + +/// Use case: fetches data from all three repositories and returns an +/// aggregated [DashboardSummary]. +/// +/// This lives in the app layer (not in a feature package) because it +/// crosses feature boundaries. +class GetDashboardSummary { + final InventoryRepository inventoryRepository; + final ProductPublishingRepository productPublishingRepository; + final OrdersRepository ordersRepository; + + GetDashboardSummary({ + required this.inventoryRepository, + required this.productPublishingRepository, + required this.ordersRepository, + }); + + Future call() async { + final results = await Future.wait([ + inventoryRepository.getInventoryItems(), + productPublishingRepository.getProductDrafts(), + ordersRepository.getOrders(), + ]); + + return DashboardSummary.fromData( + inventoryItems: results[0] as List, + productDrafts: results[1] as List, + orders: results[2] as List, + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart new file mode 100644 index 0000000..572db6d --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart @@ -0,0 +1,148 @@ +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; + +/// Aggregated summary data displayed on the dashboard. +/// +/// This is an app-level value object that composes data from multiple +/// feature-package repositories without leaking domain logic back into +/// those packages. +class DashboardSummary { + /// Total number of inventory items. + final int totalProducts; + + /// Items with [InventoryStatus.inStock]. + final int inStock; + + /// Items with [InventoryStatus.lowStock]. + final int lowStock; + + /// Items with [InventoryStatus.outOfStock]. + final int outOfStock; + + /// Product drafts with [PublishStatus.draft]. + final int draftProducts; + + /// Total number of orders. + final int totalOrders; + + /// Orders with [OrderStatus.pending]. + final int pendingOrders; + + /// Orders with [OrderStatus.processing] or [OrderStatus.shipped]. + final int activeOrders; + + /// Revenue from delivered orders. + final double deliveredRevenue; + + const DashboardSummary({ + required this.totalProducts, + required this.inStock, + required this.lowStock, + required this.outOfStock, + required this.draftProducts, + required this.totalOrders, + required this.pendingOrders, + required this.activeOrders, + required this.deliveredRevenue, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DashboardSummary && + totalProducts == other.totalProducts && + inStock == other.inStock && + lowStock == other.lowStock && + outOfStock == other.outOfStock && + draftProducts == other.draftProducts && + totalOrders == other.totalOrders && + pendingOrders == other.pendingOrders && + activeOrders == other.activeOrders && + deliveredRevenue == other.deliveredRevenue; + + @override + int get hashCode => Object.hash( + totalProducts, + inStock, + lowStock, + outOfStock, + draftProducts, + totalOrders, + pendingOrders, + activeOrders, + deliveredRevenue, + ); + + /// An empty summary used as the initial / default state. + static const empty = DashboardSummary( + totalProducts: 0, + inStock: 0, + lowStock: 0, + outOfStock: 0, + draftProducts: 0, + totalOrders: 0, + pendingOrders: 0, + activeOrders: 0, + deliveredRevenue: 0, + ); + + /// Computes a [DashboardSummary] from raw repository data. + factory DashboardSummary.fromData({ + required List inventoryItems, + required List productDrafts, + required List orders, + }) { + // Inventory counts + final totalProducts = inventoryItems.length; + var inStock = 0; + var lowStock = 0; + var outOfStock = 0; + for (final item in inventoryItems) { + switch (item.status) { + case InventoryStatus.inStock: + inStock++; + case InventoryStatus.lowStock: + lowStock++; + case InventoryStatus.outOfStock: + outOfStock++; + case InventoryStatus.draft: + break; + } + } + + // Draft product count + final draftProducts = productDrafts.where((d) => d.status == PublishStatus.draft).length; + + // Order counts + final totalOrders = orders.length; + var pendingOrders = 0; + var activeOrders = 0; + var deliveredRevenue = 0.0; + for (final order in orders) { + switch (order.status) { + case OrderStatus.pending: + pendingOrders++; + case OrderStatus.processing: + case OrderStatus.shipped: + activeOrders++; + case OrderStatus.delivered: + deliveredRevenue += order.total; + case OrderStatus.cancelled: + break; + } + } + + return DashboardSummary( + totalProducts: totalProducts, + inStock: inStock, + lowStock: lowStock, + outOfStock: outOfStock, + draftProducts: draftProducts, + totalOrders: totalOrders, + pendingOrders: pendingOrders, + activeOrders: activeOrders, + deliveredRevenue: deliveredRevenue, + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart index d6cdf8c..c0771b0 100644 --- a/kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart +++ b/kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart @@ -1,33 +1,75 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import '../dashboard/application/dashboard_controller.dart'; +import '../dashboard/domain/dashboard_summary.dart'; import '../routing/app_routes.dart'; import '../shell/widgets/empty_state_panel.dart'; import '../shell/widgets/section_header.dart'; import '../shell/widgets/summary_card.dart'; -/// The main dashboard page showing stub summary data. +/// The main dashboard page showing aggregated summary data. /// -/// Displays KPI cards (total products, in-stock, low-stock, draft) and a -/// quick-actions section. All data is hard-coded stub data until backend -/// integration is added. -class DashboardPage extends StatelessWidget { - const DashboardPage({super.key}); +/// Displays KPI cards (total products, in-stock, low-stock, draft, orders, +/// pending, active, revenue) and a quick-actions section. Data is loaded +/// from the [DashboardController] which aggregates across repositories. +class DashboardPage extends StatefulWidget { + final DashboardController controller; - // ── Stub data ────────────────────────────────────────────────────────── + const DashboardPage({super.key, required this.controller}); - static const int _totalProducts = 24; - static const int _inStock = 18; - static const int _lowStock = 4; - static const int _draft = 2; + @override + State createState() => _DashboardPageState(); +} + +class _DashboardPageState extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(_onControllerChanged); + // Kick off the initial load. + widget.controller.load(); + } + + @override + void didUpdateWidget(DashboardPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + widget.controller.load(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + super.dispose(); + } + + void _onControllerChanged() => setState(() {}); @override Widget build(BuildContext context) { + final controller = widget.controller; + + if (controller.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null) { + return Center( + child: Text('Failed to load dashboard data.', style: Theme.of(context).textTheme.bodyLarge), + ); + } + + final summary = controller.summary; + return ListView( children: [ const SectionHeader(title: 'Overview'), const SizedBox(height: KcSpacing.sm), - _buildSummaryGrid(context), + _buildSummaryGrid(context, summary), const SizedBox(height: KcSpacing.xl), SectionHeader( title: 'Quick Actions', @@ -52,7 +94,7 @@ class DashboardPage extends StatelessWidget { // ── Summary cards grid ───────────────────────────────────────────────── - Widget _buildSummaryGrid(BuildContext context) { + Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; @@ -65,29 +107,53 @@ class DashboardPage extends StatelessWidget { } final cards = [ - const SummaryCard( + SummaryCard( icon: Icons.inventory_2, iconColor: KcColors.denimBlue, label: 'Total Products', - value: '$_totalProducts', + value: '${summary.totalProducts}', ), - const SummaryCard( + SummaryCard( icon: Icons.check_circle_outline, iconColor: KcColors.success, label: 'In Stock', - value: '$_inStock', + value: '${summary.inStock}', ), - const SummaryCard( + SummaryCard( icon: Icons.warning_amber_rounded, iconColor: KcColors.warning, label: 'Low Stock', - value: '$_lowStock', + value: '${summary.lowStock}', ), - const SummaryCard( + SummaryCard( icon: Icons.edit_note, iconColor: KcColors.neutral, label: 'Draft', - value: '$_draft', + value: '${summary.draftProducts}', + ), + SummaryCard( + icon: Icons.receipt_long, + iconColor: KcColors.denimBlue, + label: 'Total Orders', + value: '${summary.totalOrders}', + ), + SummaryCard( + icon: Icons.hourglass_empty, + iconColor: KcColors.warning, + label: 'Pending Orders', + value: '${summary.pendingOrders}', + ), + SummaryCard( + icon: Icons.local_shipping_outlined, + iconColor: KcColors.success, + label: 'Active Orders', + value: '${summary.activeOrders}', + ), + SummaryCard( + icon: Icons.attach_money, + iconColor: KcColors.success, + label: 'Revenue', + value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}', ), ]; diff --git a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart index 2d041a5..a42de1b 100644 --- a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -4,6 +4,8 @@ import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:flutter/material.dart'; import '../composition/app_scope.dart'; +import '../dashboard/application/dashboard_controller.dart'; +import '../dashboard/application/get_dashboard_summary.dart'; import '../pages/dashboard_page.dart'; import '../pages/finance_placeholder_page.dart'; import '../pages/integrations_placeholder_page.dart'; @@ -22,11 +24,21 @@ abstract final class AppRoutes { static Route onGenerateRoute(RouteSettings settings) { switch (settings.name) { case dashboard: - return _buildRoute( - settings, - (context) => - const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()), - ); + return _buildRoute(settings, (context) { + final services = AppScope.of(context); + final controller = DashboardController( + GetDashboardSummary( + inventoryRepository: services.inventoryRepository, + productPublishingRepository: services.productPublishingRepository, + ordersRepository: services.ordersRepository, + ), + ); + return AppShell( + selectedRoute: dashboard, + title: 'Dashboard', + child: DashboardPage(controller: controller), + ); + }); case inventory: return _buildRoute( settings, diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart new file mode 100644 index 0000000..e8f8298 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart @@ -0,0 +1,201 @@ +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kell_web/dashboard/application/dashboard_controller.dart'; +import 'package:kell_web/dashboard/application/get_dashboard_summary.dart'; +import 'package:kell_web/dashboard/domain/dashboard_summary.dart'; + +// ── Tiny stub repositories for testing ───────────────────────────────────── + +class _StubInventoryRepository implements InventoryRepository { + final List items; + _StubInventoryRepository([this.items = const []]); + + @override + Future> getInventoryItems() async => items; +} + +class _StubProductPublishingRepository implements ProductPublishingRepository { + final List drafts; + _StubProductPublishingRepository([this.drafts = const []]); + + @override + Future> getProductDrafts() async => drafts; + + @override + Future publishDraft(String id) => throw UnimplementedError(); +} + +class _StubOrdersRepository implements OrdersRepository { + final List orders; + _StubOrdersRepository([this.orders = const []]); + + @override + Future> getOrders() async => orders; +} + +class _FailingInventoryRepository implements InventoryRepository { + @override + Future> getInventoryItems() => throw Exception('network error'); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +void main() { + group('DashboardController', () { + test('starts with empty summary and not loading', () { + final controller = DashboardController( + GetDashboardSummary( + inventoryRepository: _StubInventoryRepository(), + productPublishingRepository: _StubProductPublishingRepository(), + ordersRepository: _StubOrdersRepository(), + ), + ); + + expect(controller.isLoading, false); + expect(controller.summary, DashboardSummary.empty); + expect(controller.error, isNull); + }); + + test('load() sets isLoading then populates summary', () async { + final inventoryItems = [ + const InventoryItem( + id: '1', + sku: 'A', + name: 'A', + quantityOnHand: 10, + unitPrice: 5.0, + status: InventoryStatus.inStock, + ), + const InventoryItem( + id: '2', + sku: 'B', + name: 'B', + quantityOnHand: 2, + unitPrice: 5.0, + status: InventoryStatus.lowStock, + ), + ]; + + final controller = DashboardController( + GetDashboardSummary( + inventoryRepository: _StubInventoryRepository(inventoryItems), + productPublishingRepository: _StubProductPublishingRepository(), + ordersRepository: _StubOrdersRepository(), + ), + ); + + // Track notification sequence. + final loadingStates = []; + controller.addListener(() => loadingStates.add(controller.isLoading)); + + await controller.load(); + + // First notification: isLoading = true, second: isLoading = false. + expect(loadingStates, [true, false]); + expect(controller.isLoading, false); + expect(controller.error, isNull); + expect(controller.summary.totalProducts, 2); + expect(controller.summary.inStock, 1); + expect(controller.summary.lowStock, 1); + }); + + test('load() captures error and clears isLoading', () async { + final controller = DashboardController( + GetDashboardSummary( + inventoryRepository: _FailingInventoryRepository(), + productPublishingRepository: _StubProductPublishingRepository(), + ordersRepository: _StubOrdersRepository(), + ), + ); + + await controller.load(); + + expect(controller.isLoading, false); + expect(controller.error, isNotNull); + // Summary should remain empty on error. + expect(controller.summary, DashboardSummary.empty); + }); + + test('load() aggregates data from all three repositories', () async { + final inventoryItems = [ + const InventoryItem( + id: '1', + sku: 'A', + name: 'A', + quantityOnHand: 10, + unitPrice: 5.0, + status: InventoryStatus.inStock, + ), + ]; + + final drafts = [ + ProductDraft( + id: '1', + name: 'Draft', + description: '', + price: 10, + sku: 'D', + category: 'Cat', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026), + ), + ]; + + final orders = [ + Order( + id: '1', + customerName: 'Test', + customerEmail: 'test@test.com', + orderDate: DateTime(2026), + status: OrderStatus.pending, + items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 10.0)], + shippingAddress: '', + ), + ]; + + final controller = DashboardController( + GetDashboardSummary( + inventoryRepository: _StubInventoryRepository(inventoryItems), + productPublishingRepository: _StubProductPublishingRepository(drafts), + ordersRepository: _StubOrdersRepository(orders), + ), + ); + + await controller.load(); + + expect(controller.summary.totalProducts, 1); + expect(controller.summary.inStock, 1); + expect(controller.summary.draftProducts, 1); + expect(controller.summary.totalOrders, 1); + expect(controller.summary.pendingOrders, 1); + }); + + test('subsequent load() clears previous error', () async { + final failingController = DashboardController( + GetDashboardSummary( + inventoryRepository: _FailingInventoryRepository(), + productPublishingRepository: _StubProductPublishingRepository(), + ordersRepository: _StubOrdersRepository(), + ), + ); + + await failingController.load(); + expect(failingController.error, isNotNull); + + // Create a new controller with working repos to verify error clearing pattern. + final workingController = DashboardController( + GetDashboardSummary( + inventoryRepository: _StubInventoryRepository(), + productPublishingRepository: _StubProductPublishingRepository(), + ordersRepository: _StubOrdersRepository(), + ), + ); + + await workingController.load(); + expect(workingController.error, isNull); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart new file mode 100644 index 0000000..3b4ce7d --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart @@ -0,0 +1,326 @@ +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kell_web/dashboard/domain/dashboard_summary.dart'; + +void main() { + group('DashboardSummary.empty', () { + test('has all zeroes', () { + const s = DashboardSummary.empty; + expect(s.totalProducts, 0); + expect(s.inStock, 0); + expect(s.lowStock, 0); + expect(s.outOfStock, 0); + expect(s.draftProducts, 0); + expect(s.totalOrders, 0); + expect(s.pendingOrders, 0); + expect(s.activeOrders, 0); + expect(s.deliveredRevenue, 0.0); + }); + }); + + group('DashboardSummary.fromData', () { + test('counts inventory statuses correctly', () { + final items = [ + const InventoryItem( + id: '1', + sku: 'A', + name: 'A', + quantityOnHand: 10, + unitPrice: 5.0, + status: InventoryStatus.inStock, + ), + const InventoryItem( + id: '2', + sku: 'B', + name: 'B', + quantityOnHand: 2, + unitPrice: 5.0, + status: InventoryStatus.lowStock, + ), + const InventoryItem( + id: '3', + sku: 'C', + name: 'C', + quantityOnHand: 0, + unitPrice: 5.0, + status: InventoryStatus.outOfStock, + ), + const InventoryItem( + id: '4', + sku: 'D', + name: 'D', + quantityOnHand: 0, + unitPrice: 5.0, + status: InventoryStatus.draft, + ), + ]; + + final summary = DashboardSummary.fromData( + inventoryItems: items, + productDrafts: [], + orders: [], + ); + + expect(summary.totalProducts, 4); + expect(summary.inStock, 1); + expect(summary.lowStock, 1); + expect(summary.outOfStock, 1); + }); + + test('counts draft products from publishing repository', () { + final drafts = [ + ProductDraft( + id: '1', + name: 'A', + description: '', + price: 10, + sku: 'A', + category: 'Cat', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026), + ), + ProductDraft( + id: '2', + name: 'B', + description: '', + price: 10, + sku: 'B', + category: 'Cat', + imageUrl: '', + status: PublishStatus.published, + lastModified: DateTime(2026), + ), + ProductDraft( + id: '3', + name: 'C', + description: '', + price: 10, + sku: 'C', + category: 'Cat', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026), + ), + ]; + + final summary = DashboardSummary.fromData( + inventoryItems: [], + productDrafts: drafts, + orders: [], + ); + + expect(summary.draftProducts, 2); + }); + + test('counts order statuses and revenue correctly', () { + final orders = [ + Order( + id: '1', + customerName: 'A', + customerEmail: 'a@a.com', + orderDate: DateTime(2026), + status: OrderStatus.pending, + items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 10.0)], + shippingAddress: '', + ), + Order( + id: '2', + customerName: 'B', + customerEmail: 'b@b.com', + orderDate: DateTime(2026), + status: OrderStatus.processing, + items: const [OrderItem(productName: 'X', sku: 'X', quantity: 2, unitPrice: 5.0)], + shippingAddress: '', + ), + Order( + id: '3', + customerName: 'C', + customerEmail: 'c@c.com', + orderDate: DateTime(2026), + status: OrderStatus.shipped, + items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 20.0)], + shippingAddress: '', + ), + Order( + id: '4', + customerName: 'D', + customerEmail: 'd@d.com', + orderDate: DateTime(2026), + status: OrderStatus.delivered, + items: const [OrderItem(productName: 'X', sku: 'X', quantity: 3, unitPrice: 10.0)], + shippingAddress: '', + ), + Order( + id: '5', + customerName: 'E', + customerEmail: 'e@e.com', + orderDate: DateTime(2026), + status: OrderStatus.cancelled, + items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 100.0)], + shippingAddress: '', + ), + ]; + + final summary = DashboardSummary.fromData( + inventoryItems: [], + productDrafts: [], + orders: orders, + ); + + expect(summary.totalOrders, 5); + expect(summary.pendingOrders, 1); + expect(summary.activeOrders, 2); // processing + shipped + expect(summary.deliveredRevenue, 30.0); // 3 * 10 + }); + + test('computes full summary from fake repository data', () { + // Use the same data the fake repositories return. + final summary = DashboardSummary.fromData( + inventoryItems: const [ + InventoryItem( + id: '1', + sku: 'BC-FLR-001', + name: 'Floral Bowl Cozy', + quantityOnHand: 18, + unitPrice: 12.99, + status: InventoryStatus.inStock, + ), + InventoryItem( + id: '2', + sku: 'CS-CIT-002', + name: 'Citrus Coaster Set', + quantityOnHand: 7, + unitPrice: 16.50, + status: InventoryStatus.lowStock, + ), + InventoryItem( + id: '3', + sku: 'NL-OCN-003', + name: 'Ocean Nightlight', + quantityOnHand: 0, + unitPrice: 19.99, + status: InventoryStatus.outOfStock, + ), + InventoryItem( + id: '4', + sku: 'JG-BLU-004', + name: 'Fabric Jar Gripper', + quantityOnHand: 23, + unitPrice: 8.50, + status: InventoryStatus.inStock, + ), + InventoryItem( + id: '5', + sku: 'SH-SUN-005', + name: 'Skillet Handle Sleeve', + quantityOnHand: 5, + unitPrice: 10.99, + status: InventoryStatus.lowStock, + ), + InventoryItem( + id: '6', + sku: 'SC-SUB-006', + name: 'Sublimated Slate Coaster', + quantityOnHand: 0, + unitPrice: 14.99, + status: InventoryStatus.draft, + ), + ], + productDrafts: [ + ProductDraft( + id: '4', + name: 'Fabric Jar Gripper', + description: '', + price: 8.50, + sku: 'JG-BLU-004', + category: 'Kitchen Accessories', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 4, 2), + ), + ProductDraft( + id: '5', + name: 'Skillet Handle Sleeve', + description: '', + price: 10.99, + sku: 'SH-SUN-005', + category: 'Kitchen Accessories', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 4, 3), + ), + ], + orders: [ + Order( + id: 'KC-1001', + customerName: 'Sarah Mitchell', + customerEmail: 'sarah@example.com', + orderDate: DateTime(2026, 4, 1), + status: OrderStatus.delivered, + shippingAddress: '123 Maple St', + items: const [ + OrderItem( + productName: 'Floral Bowl Cozy', + sku: 'BC-FLR-001', + quantity: 2, + unitPrice: 12.99, + ), + OrderItem( + productName: 'Citrus Coaster Set', + sku: 'CS-CIT-002', + quantity: 1, + unitPrice: 16.50, + ), + ], + ), + Order( + id: 'KC-1002', + customerName: 'James Thornton', + customerEmail: 'james@example.com', + orderDate: DateTime(2026, 4, 2), + status: OrderStatus.shipped, + shippingAddress: '456 Oak Ave', + items: const [ + OrderItem( + productName: 'Ocean Nightlight', + sku: 'NL-OCN-003', + quantity: 1, + unitPrice: 19.99, + ), + ], + ), + Order( + id: 'KC-1003', + customerName: 'Emily Chen', + customerEmail: 'emily@example.com', + orderDate: DateTime(2026, 4, 3), + status: OrderStatus.pending, + shippingAddress: '789 Pine Rd', + items: const [ + OrderItem( + productName: 'Fabric Jar Gripper', + sku: 'JG-BLU-004', + quantity: 4, + unitPrice: 8.50, + ), + ], + ), + ], + ); + + expect(summary.totalProducts, 6); + expect(summary.inStock, 2); + expect(summary.lowStock, 2); + expect(summary.outOfStock, 1); + expect(summary.draftProducts, 2); + expect(summary.totalOrders, 3); + expect(summary.pendingOrders, 1); + expect(summary.activeOrders, 1); // shipped only + // delivered revenue: 2*12.99 + 1*16.50 = 42.48 + expect(summary.deliveredRevenue, closeTo(42.48, 0.01)); + }); + }); +}