feat(dashboard): compose live summary data across features
Validate Docs / validate-docs (push) Successful in 59s
Details
Validate Docs / validate-docs (push) Successful in 59s
Details
This commit is contained in:
parent
3330ed23b3
commit
00a667d19e
|
|
@ -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<void> load() async {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
summary = await _getDashboardSummary();
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DashboardSummary> call() async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
inventoryRepository.getInventoryItems(),
|
||||||
|
productPublishingRepository.getProductDrafts(),
|
||||||
|
ordersRepository.getOrders(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return DashboardSummary.fromData(
|
||||||
|
inventoryItems: results[0] as List<InventoryItem>,
|
||||||
|
productDrafts: results[1] as List<ProductDraft>,
|
||||||
|
orders: results[2] as List<Order>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<InventoryItem> inventoryItems,
|
||||||
|
required List<ProductDraft> productDrafts,
|
||||||
|
required List<Order> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,75 @@
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../dashboard/application/dashboard_controller.dart';
|
||||||
|
import '../dashboard/domain/dashboard_summary.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../shell/widgets/empty_state_panel.dart';
|
import '../shell/widgets/empty_state_panel.dart';
|
||||||
import '../shell/widgets/section_header.dart';
|
import '../shell/widgets/section_header.dart';
|
||||||
import '../shell/widgets/summary_card.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
|
/// Displays KPI cards (total products, in-stock, low-stock, draft, orders,
|
||||||
/// quick-actions section. All data is hard-coded stub data until backend
|
/// pending, active, revenue) and a quick-actions section. Data is loaded
|
||||||
/// integration is added.
|
/// from the [DashboardController] which aggregates across repositories.
|
||||||
class DashboardPage extends StatelessWidget {
|
class DashboardPage extends StatefulWidget {
|
||||||
const DashboardPage({super.key});
|
final DashboardController controller;
|
||||||
|
|
||||||
// ── Stub data ──────────────────────────────────────────────────────────
|
const DashboardPage({super.key, required this.controller});
|
||||||
|
|
||||||
static const int _totalProducts = 24;
|
@override
|
||||||
static const int _inStock = 18;
|
State<DashboardPage> createState() => _DashboardPageState();
|
||||||
static const int _lowStock = 4;
|
}
|
||||||
static const int _draft = 2;
|
|
||||||
|
class _DashboardPageState extends State<DashboardPage> {
|
||||||
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
const SectionHeader(title: 'Overview'),
|
const SectionHeader(title: 'Overview'),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
_buildSummaryGrid(context),
|
_buildSummaryGrid(context, summary),
|
||||||
const SizedBox(height: KcSpacing.xl),
|
const SizedBox(height: KcSpacing.xl),
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
title: 'Quick Actions',
|
title: 'Quick Actions',
|
||||||
|
|
@ -52,7 +94,7 @@ class DashboardPage extends StatelessWidget {
|
||||||
|
|
||||||
// ── Summary cards grid ─────────────────────────────────────────────────
|
// ── Summary cards grid ─────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _buildSummaryGrid(BuildContext context) {
|
Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
|
|
@ -65,29 +107,53 @@ class DashboardPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
final cards = [
|
final cards = [
|
||||||
const SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.inventory_2,
|
icon: Icons.inventory_2,
|
||||||
iconColor: KcColors.denimBlue,
|
iconColor: KcColors.denimBlue,
|
||||||
label: 'Total Products',
|
label: 'Total Products',
|
||||||
value: '$_totalProducts',
|
value: '${summary.totalProducts}',
|
||||||
),
|
),
|
||||||
const SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.check_circle_outline,
|
icon: Icons.check_circle_outline,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'In Stock',
|
label: 'In Stock',
|
||||||
value: '$_inStock',
|
value: '${summary.inStock}',
|
||||||
),
|
),
|
||||||
const SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
iconColor: KcColors.warning,
|
iconColor: KcColors.warning,
|
||||||
label: 'Low Stock',
|
label: 'Low Stock',
|
||||||
value: '$_lowStock',
|
value: '${summary.lowStock}',
|
||||||
),
|
),
|
||||||
const SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.edit_note,
|
icon: Icons.edit_note,
|
||||||
iconColor: KcColors.neutral,
|
iconColor: KcColors.neutral,
|
||||||
label: 'Draft',
|
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)}',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../composition/app_scope.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/dashboard_page.dart';
|
||||||
import '../pages/finance_placeholder_page.dart';
|
import '../pages/finance_placeholder_page.dart';
|
||||||
import '../pages/integrations_placeholder_page.dart';
|
import '../pages/integrations_placeholder_page.dart';
|
||||||
|
|
@ -22,11 +24,21 @@ abstract final class AppRoutes {
|
||||||
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
|
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
|
||||||
switch (settings.name) {
|
switch (settings.name) {
|
||||||
case dashboard:
|
case dashboard:
|
||||||
return _buildRoute(
|
return _buildRoute(settings, (context) {
|
||||||
settings,
|
final services = AppScope.of(context);
|
||||||
(context) =>
|
final controller = DashboardController(
|
||||||
const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()),
|
GetDashboardSummary(
|
||||||
|
inventoryRepository: services.inventoryRepository,
|
||||||
|
productPublishingRepository: services.productPublishingRepository,
|
||||||
|
ordersRepository: services.ordersRepository,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return AppShell(
|
||||||
|
selectedRoute: dashboard,
|
||||||
|
title: 'Dashboard',
|
||||||
|
child: DashboardPage(controller: controller),
|
||||||
|
);
|
||||||
|
});
|
||||||
case inventory:
|
case inventory:
|
||||||
return _buildRoute(
|
return _buildRoute(
|
||||||
settings,
|
settings,
|
||||||
|
|
|
||||||
|
|
@ -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<InventoryItem> items;
|
||||||
|
_StubInventoryRepository([this.items = const []]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<InventoryItem>> getInventoryItems() async => items;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StubProductPublishingRepository implements ProductPublishingRepository {
|
||||||
|
final List<ProductDraft> drafts;
|
||||||
|
_StubProductPublishingRepository([this.drafts = const []]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductDraft>> getProductDrafts() async => drafts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StubOrdersRepository implements OrdersRepository {
|
||||||
|
final List<Order> orders;
|
||||||
|
_StubOrdersRepository([this.orders = const []]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Order>> getOrders() async => orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FailingInventoryRepository implements InventoryRepository {
|
||||||
|
@override
|
||||||
|
Future<List<InventoryItem>> 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 = <bool>[];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue