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
15 changed files with 181 additions and 37 deletions
Showing only changes of commit e06c2d8f94 - Show all commits

View File

@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
import 'app_services.dart';
/// An [InheritedWidget] that exposes [AppServices] to the widget tree.
///
/// Wrap the app (or a subtree) with [AppScope] and retrieve the services
/// anywhere below via [AppScope.of(context)].
class AppScope extends InheritedWidget {
final AppServices services;
const AppScope({super.key, required this.services, required super.child});
/// Returns the nearest [AppServices] from the widget tree.
///
/// Throws if no [AppScope] ancestor is found.
static AppServices of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'No AppScope found in the widget tree');
return scope!.services;
}
@override
bool updateShouldNotify(AppScope oldWidget) => services != oldWidget.services;
}

View File

@ -0,0 +1,22 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
/// Holds the concrete service implementations used by the app.
///
/// The [AppServices.fake] factory wires up the in-memory fakes that live
/// inside each feature package. Swap this factory for a real one when
/// production backends are ready.
class AppServices {
final InventoryRepository inventoryRepository;
final ProductPublishingRepository productPublishingRepository;
const AppServices({required this.inventoryRepository, required this.productPublishingRepository});
/// Creates an [AppServices] backed by fake, in-memory repositories.
factory AppServices.fake() {
return AppServices(
inventoryRepository: FakeInventoryRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
);
}
}

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app.dart'; import 'app.dart';
import 'composition/app_scope.dart';
import 'composition/app_services.dart';
void main() { void main() {
runApp(const KellWebApp()); final services = AppServices.fake();
runApp(AppScope(services: services, child: const KellWebApp()));
} }

View File

@ -2,6 +2,7 @@ import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../composition/app_scope.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';
@ -23,41 +24,60 @@ abstract final class AppRoutes {
case dashboard: case dashboard:
return _buildRoute( return _buildRoute(
settings, settings,
(context) =>
const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()), const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()),
); );
case inventory: case inventory:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell(selectedRoute: inventory, title: 'Inventory', child: InventoryPage()), (context) => AppShell(
selectedRoute: inventory,
title: 'Inventory',
child: InventoryPage(repository: AppScope.of(context).inventoryRepository),
),
); );
case products: case products:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell( (context) => AppShell(
selectedRoute: products, selectedRoute: products,
title: 'Products', title: 'Products',
child: ProductPublishingPage(), child: ProductPublishingPage(
repository: AppScope.of(context).productPublishingRepository,
),
), ),
); );
case orders: case orders:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell(selectedRoute: orders, title: 'Orders', child: OrdersPlaceholderPage()), (context) => const AppShell(
selectedRoute: orders,
title: 'Orders',
child: OrdersPlaceholderPage(),
),
); );
case finance: case finance:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell(selectedRoute: finance, title: 'Finance', child: FinancePlaceholderPage()), (context) => const AppShell(
selectedRoute: finance,
title: 'Finance',
child: FinancePlaceholderPage(),
),
); );
case policy: case policy:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell(selectedRoute: policy, title: 'Policy', child: PolicyPlaceholderPage()), (context) => const AppShell(
selectedRoute: policy,
title: 'Policy',
child: PolicyPlaceholderPage(),
),
); );
case integrations: case integrations:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell( (context) => const AppShell(
selectedRoute: integrations, selectedRoute: integrations,
title: 'Integrations', title: 'Integrations',
child: IntegrationsPlaceholderPage(), child: IntegrationsPlaceholderPage(),
@ -66,12 +86,19 @@ abstract final class AppRoutes {
default: default:
return _buildRoute( return _buildRoute(
settings, settings,
const AppShell(selectedRoute: inventory, title: 'Inventory', child: InventoryPage()), (context) => AppShell(
selectedRoute: inventory,
title: 'Inventory',
child: InventoryPage(repository: AppScope.of(context).inventoryRepository),
),
); );
} }
} }
static MaterialPageRoute<dynamic> _buildRoute(RouteSettings settings, Widget page) { static MaterialPageRoute<dynamic> _buildRoute(
return MaterialPageRoute<dynamic>(settings: settings, builder: (_) => page); RouteSettings settings,
Widget Function(BuildContext context) pageBuilder,
) {
return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder);
} }
} }

View File

@ -1,10 +1,16 @@
import 'package:flutter/material.dart'; 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';
import 'package:kell_web/composition/app_scope.dart';
import 'package:kell_web/composition/app_services.dart';
Widget _buildTestApp() {
return AppScope(services: AppServices.fake(), child: const KellWebApp());
}
void main() { void main() {
testWidgets('app shell loads dashboard route', (WidgetTester tester) async { testWidgets('app shell loads dashboard route', (WidgetTester tester) async {
await tester.pumpWidget(const KellWebApp()); await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Kell Creations'), findsOneWidget); expect(find.text('Kell Creations'), findsOneWidget);
@ -12,7 +18,7 @@ void main() {
}); });
testWidgets('dashboard shows summary cards', (WidgetTester tester) async { testWidgets('dashboard shows summary cards', (WidgetTester tester) async {
await tester.pumpWidget(const KellWebApp()); await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Overview'), findsOneWidget); expect(find.text('Overview'), findsOneWidget);
@ -23,7 +29,7 @@ void main() {
}); });
testWidgets('dashboard shows quick actions section', (WidgetTester tester) async { testWidgets('dashboard shows quick actions section', (WidgetTester tester) async {
await tester.pumpWidget(const KellWebApp()); await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Scroll down to reveal the quick actions section. // Scroll down to reveal the quick actions section.
@ -41,7 +47,7 @@ void main() {
}); });
testWidgets('dashboard shows recent activity empty state', (WidgetTester tester) async { testWidgets('dashboard shows recent activity empty state', (WidgetTester tester) async {
await tester.pumpWidget(const KellWebApp()); await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Scroll down to reveal the recent activity section. // Scroll down to reveal the recent activity section.

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/kc_colors.dart';
class KcStatusChip extends StatelessWidget { class KcStatusChip extends StatelessWidget {
final String label; final String label;

View File

@ -1,12 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/main.dart';
import 'package:design_system/design_system.dart';
void main() { void main() {
testWidgets('app renders inventory page', (WidgetTester tester) async { group('KcStatusChip', () {
await tester.pumpWidget(const KellWebApp()); testWidgets('renders label text', (WidgetTester tester) async {
await tester.pumpAndSettle(); await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: KcStatusChip(label: 'Active', background: Colors.green, foreground: Colors.white),
),
),
);
expect(find.text('Kell Creations'), findsOneWidget); expect(find.text('Active'), findsOneWidget);
expect(find.text('Inventory'), findsOneWidget); });
});
group('KcCard', () {
testWidgets('renders child widget', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: KcCard(child: Text('Hello'))),
),
);
expect(find.text('Hello'), findsOneWidget);
});
});
group('buildKcTheme', () {
test('returns a ThemeData', () {
final theme = buildKcTheme();
expect(theme, isA<ThemeData>());
});
}); });
} }

View File

@ -1,5 +1,7 @@
library; library;
export 'src/presentation/inventory_page.dart'; export 'src/data/fake_inventory_repository.dart';
export 'src/domain/inventory_item.dart'; export 'src/domain/inventory_item.dart';
export 'src/domain/inventory_repository.dart';
export 'src/domain/inventory_status.dart'; export 'src/domain/inventory_status.dart';
export 'src/presentation/inventory_page.dart';

View File

@ -3,11 +3,13 @@ import 'package:flutter/material.dart';
import '../application/get_inventory_items.dart'; import '../application/get_inventory_items.dart';
import '../application/inventory_controller.dart'; import '../application/inventory_controller.dart';
import '../data/fake_inventory_repository.dart'; import '../domain/inventory_repository.dart';
import 'widgets/inventory_item_card.dart'; import 'widgets/inventory_item_card.dart';
class InventoryPage extends StatefulWidget { class InventoryPage extends StatefulWidget {
const InventoryPage({super.key}); final InventoryRepository repository;
const InventoryPage({super.key, required this.repository});
@override @override
State<InventoryPage> createState() => _InventoryPageState(); State<InventoryPage> createState() => _InventoryPageState();
@ -19,7 +21,7 @@ class _InventoryPageState extends State<InventoryPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = InventoryController(GetInventoryItems(FakeInventoryRepository())); controller = InventoryController(GetInventoryItems(widget.repository));
controller.load(); controller.load();
} }

View File

@ -1,6 +1,7 @@
name: feature_inventory name: feature_inventory
description: "A new Flutter package project." description: "A new Flutter package project."
version: 0.0.1 version: 0.0.1
publish_to: "none"
homepage: homepage:
environment: environment:

View File

@ -3,10 +3,37 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:feature_inventory/feature_inventory.dart'; import 'package:feature_inventory/feature_inventory.dart';
void main() { void main() {
test('adds one to input values', () { group('InventoryStatus', () {
final calculator = Calculator(); test('has four values', () {
expect(calculator.addOne(2), 3); expect(InventoryStatus.values.length, 4);
expect(calculator.addOne(-7), -6); });
expect(calculator.addOne(0), 1);
test('contains expected statuses', () {
expect(InventoryStatus.values, contains(InventoryStatus.inStock));
expect(InventoryStatus.values, contains(InventoryStatus.lowStock));
expect(InventoryStatus.values, contains(InventoryStatus.outOfStock));
expect(InventoryStatus.values, contains(InventoryStatus.draft));
});
});
group('FakeInventoryRepository', () {
late FakeInventoryRepository repository;
setUp(() {
repository = FakeInventoryRepository();
});
test('returns six sample items', () async {
final items = await repository.getInventoryItems();
expect(items.length, 6);
});
test('returns items with expected names', () async {
final items = await repository.getInventoryItems();
final names = items.map((i) => i.name).toList();
expect(names, contains('Floral Bowl Cozy'));
expect(names, contains('Citrus Coaster Set'));
expect(names, contains('Ocean Nightlight'));
});
}); });
} }

View File

@ -1,5 +1,7 @@
library; library;
export 'src/data/fake_product_publishing_repository.dart';
export 'src/domain/product_draft.dart'; export 'src/domain/product_draft.dart';
export 'src/domain/product_publishing_repository.dart';
export 'src/domain/publish_status.dart'; export 'src/domain/publish_status.dart';
export 'src/presentation/product_publishing_page.dart'; export 'src/presentation/product_publishing_page.dart';

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import '../application/get_product_drafts.dart'; import '../application/get_product_drafts.dart';
import '../application/product_publishing_controller.dart'; import '../application/product_publishing_controller.dart';
import '../application/publish_product.dart'; import '../application/publish_product.dart';
import '../data/fake_product_publishing_repository.dart'; import '../domain/product_publishing_repository.dart';
import 'widgets/product_draft_card.dart'; import 'widgets/product_draft_card.dart';
import 'widgets/product_preview_panel.dart'; import 'widgets/product_preview_panel.dart';
@ -13,7 +13,9 @@ import 'widgets/product_preview_panel.dart';
/// Displays a list of product drafts on the left and a preview panel on the /// Displays a list of product drafts on the left and a preview panel on the
/// right. Users can select a draft to preview and publish it to the store. /// right. Users can select a draft to preview and publish it to the store.
class ProductPublishingPage extends StatefulWidget { class ProductPublishingPage extends StatefulWidget {
const ProductPublishingPage({super.key}); final ProductPublishingRepository repository;
const ProductPublishingPage({super.key, required this.repository});
@override @override
State<ProductPublishingPage> createState() => _ProductPublishingPageState(); State<ProductPublishingPage> createState() => _ProductPublishingPageState();
@ -25,7 +27,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final repo = FakeProductPublishingRepository(); final repo = widget.repository;
controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo)); controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo));
controller.load(); controller.load();
} }

View File

@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:feature_wordpress/src/data/fake_product_publishing_repository.dart';
void main() { void main() {
late FakeProductPublishingRepository repository; late FakeProductPublishingRepository repository;

View File

@ -4,7 +4,6 @@ import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:feature_wordpress/src/application/get_product_drafts.dart'; import 'package:feature_wordpress/src/application/get_product_drafts.dart';
import 'package:feature_wordpress/src/application/product_publishing_controller.dart'; import 'package:feature_wordpress/src/application/product_publishing_controller.dart';
import 'package:feature_wordpress/src/application/publish_product.dart'; import 'package:feature_wordpress/src/application/publish_product.dart';
import 'package:feature_wordpress/src/data/fake_product_publishing_repository.dart';
void main() { void main() {
late FakeProductPublishingRepository repository; late FakeProductPublishingRepository repository;