Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1
|
|
@ -11,7 +11,7 @@ class KellWebApp extends StatelessWidget {
|
||||||
title: 'Kell Creations',
|
title: 'Kell Creations',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: buildKcTheme(),
|
theme: buildKcTheme(),
|
||||||
initialRoute: AppRoutes.inventory,
|
initialRoute: AppRoutes.dashboard,
|
||||||
onGenerateRoute: AppRoutes.onGenerateRoute,
|
onGenerateRoute: AppRoutes.onGenerateRoute,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
import 'package:feature_inventory/feature_inventory.dart';
|
||||||
import 'package:flutter/material.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/finance_placeholder_page.dart';
|
||||||
import '../pages/integrations_placeholder_page.dart';
|
import '../pages/integrations_placeholder_page.dart';
|
||||||
import '../pages/orders_placeholder_page.dart';
|
import '../pages/orders_placeholder_page.dart';
|
||||||
|
|
@ -23,11 +23,7 @@ abstract final class AppRoutes {
|
||||||
case dashboard:
|
case dashboard:
|
||||||
return _buildRoute(
|
return _buildRoute(
|
||||||
settings,
|
settings,
|
||||||
const AppShell(
|
const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()),
|
||||||
selectedRoute: dashboard,
|
|
||||||
title: 'Dashboard',
|
|
||||||
child: DashboardPlaceholderPage(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
case inventory:
|
case inventory:
|
||||||
return _buildRoute(
|
return _buildRoute(
|
||||||
|
|
|
||||||
|
|
@ -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!],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,57 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kell_web/app.dart';
|
import 'package:kell_web/app.dart';
|
||||||
|
|
||||||
void main() {
|
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.pumpWidget(const KellWebApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Kell Creations'), findsOneWidget);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue