Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1

Merged
mtkell merged 12 commits from feat/inventory-first-slice into main 2026-04-04 19:46:31 +00:00
7 changed files with 304 additions and 9 deletions
Showing only changes of commit 6b0e16dec6 - Show all commits

View File

@ -11,7 +11,7 @@ class KellWebApp extends StatelessWidget {
title: 'Kell Creations',
debugShowCheckedModeBanner: false,
theme: buildKcTheme(),
initialRoute: AppRoutes.inventory,
initialRoute: AppRoutes.dashboard,
onGenerateRoute: AppRoutes.onGenerateRoute,
);
}

View File

@ -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);
}
}

View File

@ -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(

View File

@ -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!],
],
),
),
),
);
}
}

View File

@ -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,
],
),
);
}
}

View File

@ -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)),
],
),
);
}
}

View File

@ -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);
});
}