Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1
|
|
@ -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: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<DashboardPage> createState() => _DashboardPageState();
|
||||
}
|
||||
|
||||
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
|
||||
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)}',
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dynamic> 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,
|
||||
|
|
|
|||
|
|
@ -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