From 6b0e16dec638963a88d456814d4a5b83cd076093 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 4 Apr 2026 12:51:08 -0400 Subject: [PATCH] feat(kell-web): add dashboard page and reusable shell widgets --- .../apps/kell_web/lib/app.dart | 2 +- .../kell_web/lib/pages/dashboard_page.dart | 149 ++++++++++++++++++ .../apps/kell_web/lib/routing/app_routes.dart | 8 +- .../lib/shell/widgets/empty_state_panel.dart | 38 +++++ .../lib/shell/widgets/section_header.dart | 27 ++++ .../lib/shell/widgets/summary_card.dart | 40 +++++ .../apps/kell_web/test/widget_test.dart | 49 +++++- 7 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/shell/widgets/empty_state_panel.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/shell/widgets/section_header.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/shell/widgets/summary_card.dart diff --git a/kell_creations_apps/apps/kell_web/lib/app.dart b/kell_creations_apps/apps/kell_web/lib/app.dart index 2368a83..4fe98c2 100644 --- a/kell_creations_apps/apps/kell_web/lib/app.dart +++ b/kell_creations_apps/apps/kell_web/lib/app.dart @@ -11,7 +11,7 @@ class KellWebApp extends StatelessWidget { title: 'Kell Creations', debugShowCheckedModeBanner: false, theme: buildKcTheme(), - initialRoute: AppRoutes.inventory, + initialRoute: AppRoutes.dashboard, onGenerateRoute: AppRoutes.onGenerateRoute, ); } 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 new file mode 100644 index 0000000..d6cdf8c --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart @@ -0,0 +1,149 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.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. +/// +/// 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}); + + // ── Stub data ────────────────────────────────────────────────────────── + + static const int _totalProducts = 24; + static const int _inStock = 18; + static const int _lowStock = 4; + static const int _draft = 2; + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + const SectionHeader(title: 'Overview'), + const SizedBox(height: KcSpacing.sm), + _buildSummaryGrid(context), + const SizedBox(height: KcSpacing.xl), + SectionHeader( + title: 'Quick Actions', + action: TextButton( + onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory), + child: const Text('Go to Inventory'), + ), + ), + const SizedBox(height: KcSpacing.sm), + _buildQuickActions(context), + const SizedBox(height: KcSpacing.xl), + const SectionHeader(title: 'Recent Activity'), + const SizedBox(height: KcSpacing.sm), + const EmptyStatePanel( + icon: Icons.history, + message: + 'No recent activity yet.\nActivity will appear here once orders and updates are tracked.', + ), + ], + ); + } + + // ── Summary cards grid ───────────────────────────────────────────────── + + Widget _buildSummaryGrid(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + + int crossAxisCount = 2; + if (width >= 900) { + crossAxisCount = 4; + } else if (width >= 600) { + crossAxisCount = 2; + } + + final cards = [ + const SummaryCard( + icon: Icons.inventory_2, + iconColor: KcColors.denimBlue, + label: 'Total Products', + value: '$_totalProducts', + ), + const SummaryCard( + icon: Icons.check_circle_outline, + iconColor: KcColors.success, + label: 'In Stock', + value: '$_inStock', + ), + const SummaryCard( + icon: Icons.warning_amber_rounded, + iconColor: KcColors.warning, + label: 'Low Stock', + value: '$_lowStock', + ), + const SummaryCard( + icon: Icons.edit_note, + iconColor: KcColors.neutral, + label: 'Draft', + value: '$_draft', + ), + ]; + + return GridView.count( + crossAxisCount: crossAxisCount, + crossAxisSpacing: KcSpacing.md, + mainAxisSpacing: KcSpacing.md, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 1.8, + children: cards, + ); + }, + ); + } + + // ── Quick actions ────────────────────────────────────────────────────── + + Widget _buildQuickActions(BuildContext context) { + return KcCard( + child: Wrap( + spacing: KcSpacing.sm, + runSpacing: KcSpacing.sm, + children: [ + _QuickActionChip( + icon: Icons.add, + label: 'New Product', + onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.products), + ), + _QuickActionChip( + icon: Icons.inventory_2_outlined, + label: 'View Inventory', + onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory), + ), + _QuickActionChip( + icon: Icons.receipt_long_outlined, + label: 'View Orders', + onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.orders), + ), + ], + ), + ); + } +} + +// ── Private helper widget ────────────────────────────────────────────────── + +class _QuickActionChip extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + + const _QuickActionChip({required this.icon, required this.label, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return ActionChip(avatar: Icon(icon, size: 18), label: Text(label), onPressed: onPressed); + } +} 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 000da0f..5286f36 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 @@ -1,7 +1,7 @@ import 'package:feature_inventory/feature_inventory.dart'; import 'package:flutter/material.dart'; -import '../pages/dashboard_placeholder_page.dart'; +import '../pages/dashboard_page.dart'; import '../pages/finance_placeholder_page.dart'; import '../pages/integrations_placeholder_page.dart'; import '../pages/orders_placeholder_page.dart'; @@ -23,11 +23,7 @@ abstract final class AppRoutes { case dashboard: return _buildRoute( settings, - const AppShell( - selectedRoute: dashboard, - title: 'Dashboard', - child: DashboardPlaceholderPage(), - ), + const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()), ); case inventory: return _buildRoute( diff --git a/kell_creations_apps/apps/kell_web/lib/shell/widgets/empty_state_panel.dart b/kell_creations_apps/apps/kell_web/lib/shell/widgets/empty_state_panel.dart new file mode 100644 index 0000000..fd7056c --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/shell/widgets/empty_state_panel.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A reusable empty-state panel shown when a section has no data yet. +/// +/// Displays an [icon], a [message], and an optional [action] widget +/// (e.g. a button to create the first item). +class EmptyStatePanel extends StatelessWidget { + final IconData icon; + final String message; + final Widget? action; + + const EmptyStatePanel({super.key, required this.icon, required this.message, this.action}); + + @override + Widget build(BuildContext context) { + return KcCard( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: KcColors.neutral), + const SizedBox(height: KcSpacing.md), + Text( + message, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.neutral), + textAlign: TextAlign.center, + ), + if (action != null) ...[const SizedBox(height: KcSpacing.md), action!], + ], + ), + ), + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/shell/widgets/section_header.dart b/kell_creations_apps/apps/kell_web/lib/shell/widgets/section_header.dart new file mode 100644 index 0000000..f49e0e0 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/shell/widgets/section_header.dart @@ -0,0 +1,27 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A reusable section header used across app pages. +/// +/// Displays a [title] with an optional trailing [action] widget +/// (e.g. a "View all" button). +class SectionHeader extends StatelessWidget { + final String title; + final Widget? action; + + const SectionHeader({super.key, required this.title, this.action}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: KcSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + ?action, + ], + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/shell/widgets/summary_card.dart b/kell_creations_apps/apps/kell_web/lib/shell/widgets/summary_card.dart new file mode 100644 index 0000000..06af4db --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/shell/widgets/summary_card.dart @@ -0,0 +1,40 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A stat / summary card that displays a [value] with a [label] and an [icon]. +/// +/// Used on the dashboard to show high-level KPIs such as total products, +/// in-stock count, etc. +class SummaryCard extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String label; + final String value; + + const SummaryCard({ + super.key, + required this.icon, + required this.iconColor, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KcCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: iconColor, size: 28), + const SizedBox(height: KcSpacing.sm), + Text(value, style: theme.textTheme.headlineMedium?.copyWith(fontSize: 32)), + const SizedBox(height: KcSpacing.xs), + Text(label, style: theme.textTheme.bodyMedium?.copyWith(color: KcColors.neutral)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/test/widget_test.dart b/kell_creations_apps/apps/kell_web/test/widget_test.dart index c897302..94ab6ee 100644 --- a/kell_creations_apps/apps/kell_web/test/widget_test.dart +++ b/kell_creations_apps/apps/kell_web/test/widget_test.dart @@ -1,12 +1,57 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kell_web/app.dart'; void main() { - testWidgets('app shell loads inventory route', (WidgetTester tester) async { + testWidgets('app shell loads dashboard route', (WidgetTester tester) async { await tester.pumpWidget(const KellWebApp()); await tester.pumpAndSettle(); expect(find.text('Kell Creations'), findsOneWidget); - expect(find.text('Inventory'), findsWidgets); + expect(find.text('Dashboard'), findsWidgets); + }); + + testWidgets('dashboard shows summary cards', (WidgetTester tester) async { + await tester.pumpWidget(const KellWebApp()); + await tester.pumpAndSettle(); + + expect(find.text('Overview'), findsOneWidget); + expect(find.text('Total Products'), findsOneWidget); + expect(find.text('In Stock'), findsOneWidget); + expect(find.text('Low Stock'), findsOneWidget); + expect(find.text('Draft'), findsOneWidget); + }); + + testWidgets('dashboard shows quick actions section', (WidgetTester tester) async { + await tester.pumpWidget(const KellWebApp()); + await tester.pumpAndSettle(); + + // Scroll down to reveal the quick actions section. + await tester.scrollUntilVisible( + find.text('Quick Actions'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.text('Quick Actions'), findsOneWidget); + expect(find.text('New Product'), findsOneWidget); + expect(find.text('View Inventory'), findsOneWidget); + expect(find.text('View Orders'), findsOneWidget); + }); + + testWidgets('dashboard shows recent activity empty state', (WidgetTester tester) async { + await tester.pumpWidget(const KellWebApp()); + await tester.pumpAndSettle(); + + // Scroll down to reveal the recent activity section. + await tester.scrollUntilVisible( + find.text('Recent Activity'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.text('Recent Activity'), findsOneWidget); }); }