diff --git a/kell_creations_apps/apps/kell_web/lib/app.dart b/kell_creations_apps/apps/kell_web/lib/app.dart new file mode 100644 index 0000000..4fe98c2 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/app.dart @@ -0,0 +1,18 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'routing/app_routes.dart'; + +class KellWebApp extends StatelessWidget { + const KellWebApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Kell Creations', + debugShowCheckedModeBanner: false, + theme: buildKcTheme(), + initialRoute: AppRoutes.dashboard, + onGenerateRoute: AppRoutes.onGenerateRoute, + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart new file mode 100644 index 0000000..340d33a --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart @@ -0,0 +1,85 @@ +/// Runtime configuration read from `--dart-define` values. +/// +/// The app reads the following compile-time constants: +/// +/// | Key | Description | Default | +/// |------------------------|----------------------------------------------|----------| +/// | `KC_ENV` | `fake` or `wordpress` | `fake` | +/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) | +/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | +/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) | +/// +/// Usage: +/// ```sh +/// flutter run -d chrome \ +/// --dart-define=KC_ENV=wordpress \ +/// --dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \ +/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ +/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx +/// ``` +class AppConfig { + /// The environment mode: `fake` or `wordpress`. + final AppEnvironment environment; + + /// WordPress / WooCommerce site URL (only used in [AppEnvironment.wordpress]). + final String wcSiteUrl; + + /// WooCommerce REST API consumer key. + final String wcConsumerKey; + + /// WooCommerce REST API consumer secret. + final String wcConsumerSecret; + + const AppConfig({ + required this.environment, + required this.wcSiteUrl, + required this.wcConsumerKey, + required this.wcConsumerSecret, + }); + + /// Reads configuration from compile-time `--dart-define` constants. + factory AppConfig.fromEnvironment() { + const envString = String.fromEnvironment('KC_ENV', defaultValue: 'fake'); + const siteUrl = String.fromEnvironment('KC_WC_SITE_URL'); + const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY'); + const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET'); + + final environment = AppEnvironment.fromString(envString); + + return AppConfig( + environment: environment, + wcSiteUrl: siteUrl, + wcConsumerKey: consumerKey, + wcConsumerSecret: consumerSecret, + ); + } + + /// Whether the WordPress configuration values are all present and non-empty. + bool get hasWordPressConfig => + wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty; + + /// A human-readable label for the current environment (e.g. for badges). + String get environmentLabel => environment.label; +} + +/// The supported runtime environments. +enum AppEnvironment { + /// In-memory fakes – no network calls. + fake('fake', 'FAKE'), + + /// Real WooCommerce backend. + wordpress('wordpress', 'WP'); + + final String key; + final String label; + + const AppEnvironment(this.key, this.label); + + /// Parses a string into an [AppEnvironment], defaulting to [fake]. + static AppEnvironment fromString(String value) { + for (final env in values) { + if (env.key == value.toLowerCase()) return env; + } + return fake; + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart new file mode 100644 index 0000000..577b756 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; + +import 'app_config.dart'; +import 'app_services.dart'; + +/// An [InheritedWidget] that exposes [AppServices] and [AppConfig] 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; + final AppConfig config; + + const AppScope({super.key, required this.services, required this.config, 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(); + assert(scope != null, 'No AppScope found in the widget tree'); + return scope!.services; + } + + /// Returns the nearest [AppConfig] from the widget tree. + /// + /// Throws if no [AppScope] ancestor is found. + static AppConfig configOf(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'No AppScope found in the widget tree'); + return scope!.config; + } + + @override + bool updateShouldNotify(AppScope oldWidget) => + services != oldWidget.services || config != oldWidget.config; +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart new file mode 100644 index 0000000..d0f9b34 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart @@ -0,0 +1,60 @@ +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_policy/feature_policy.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. The [AppServices.wordpress] factory wires up +/// a real WooCommerce-backed product repository while keeping other services +/// fake until their backends are ready. +class AppServices { + final InventoryRepository inventoryRepository; + final OrdersRepository ordersRepository; + final PolicyRepository policyRepository; + final ProductPublishingRepository productPublishingRepository; + + const AppServices({ + required this.inventoryRepository, + required this.ordersRepository, + required this.policyRepository, + required this.productPublishingRepository, + }); + + /// Creates an [AppServices] backed by fake, in-memory repositories. + factory AppServices.fake() { + return AppServices( + inventoryRepository: FakeInventoryRepository(), + ordersRepository: FakeOrdersRepository(), + policyRepository: FakePolicyRepository(), + productPublishingRepository: FakeProductPublishingRepository(), + ); + } + + /// Creates an [AppServices] with a real WooCommerce-backed product + /// repository. Other repositories remain fake until their backends are + /// ready. + /// + /// [siteUrl] – the WordPress site URL (e.g. `https://store.kellcreations.com`). + /// [consumerKey] – WooCommerce REST API consumer key. + /// [consumerSecret] – WooCommerce REST API consumer secret. + factory AppServices.wordpress({ + required String siteUrl, + required String consumerKey, + required String consumerSecret, + }) { + final apiClient = WooCommerceApiClient( + siteUrl: siteUrl, + consumerKey: consumerKey, + consumerSecret: consumerSecret, + ); + + return AppServices( + inventoryRepository: FakeInventoryRepository(), + ordersRepository: FakeOrdersRepository(), + policyRepository: FakePolicyRepository(), + productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart b/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart new file mode 100644 index 0000000..8fc3dfa --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart @@ -0,0 +1,56 @@ +import 'package:flutter/foundation.dart'; + +import 'app_config.dart'; +import 'app_services.dart'; + +/// Bootstraps [AppServices] from the runtime [AppConfig]. +/// +/// In **fake** mode the in-memory fakes are used unconditionally. +/// +/// In **wordpress** mode the WooCommerce credentials are validated first. +/// If any credential is missing the app falls back to fake mode and logs a +/// warning so the developer knows what went wrong. +class Bootstrap { + const Bootstrap._(); + + /// Creates the appropriate [AppServices] for the given [config]. + /// + /// Returns a record containing the resolved services and the effective + /// config (which may differ from the input when WordPress credentials are + /// missing and a fallback to fake mode occurs). + static ({AppServices services, AppConfig config}) run(AppConfig config) { + switch (config.environment) { + case AppEnvironment.fake: + return (services: AppServices.fake(), config: config); + + case AppEnvironment.wordpress: + if (!config.hasWordPressConfig) { + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=wordpress but WooCommerce credentials are missing.\n' + ' Falling back to fake mode.\n' + ' Set KC_WC_SITE_URL, KC_WC_CONSUMER_KEY, and ' + 'KC_WC_CONSUMER_SECRET via --dart-define.', + ); + } + final fallbackConfig = AppConfig( + environment: AppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: AppServices.fake(), config: fallbackConfig); + } + + return ( + services: AppServices.wordpress( + siteUrl: config.wcSiteUrl, + consumerKey: config.wcConsumerKey, + consumerSecret: config.wcConsumerSecret, + ), + config: config, + ); + } + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart new file mode 100644 index 0000000..65ea4b8 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/application/dashboard_controller.dart @@ -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 load() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + summary = await _getDashboardSummary(); + } catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart new file mode 100644 index 0000000..6eb7653 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/application/get_dashboard_summary.dart @@ -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 call() async { + final results = await Future.wait([ + inventoryRepository.getInventoryItems(), + productPublishingRepository.getProductDrafts(), + ordersRepository.getOrders(), + ]); + + return DashboardSummary.fromData( + inventoryItems: results[0] as List, + productDrafts: results[1] as List, + orders: results[2] as List, + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart new file mode 100644 index 0000000..572db6d --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart @@ -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 inventoryItems, + required List productDrafts, + required List 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, + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/main.dart b/kell_creations_apps/apps/kell_web/lib/main.dart index 244a702..140f18f 100644 --- a/kell_creations_apps/apps/kell_web/lib/main.dart +++ b/kell_creations_apps/apps/kell_web/lib/main.dart @@ -1,122 +1,13 @@ import 'package:flutter/material.dart'; +import 'app.dart'; +import 'composition/app_config.dart'; +import 'composition/app_scope.dart'; +import 'composition/bootstrap.dart'; + void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), - ); - } + final config = AppConfig.fromEnvironment(); + final (:services, config: effectiveConfig) = Bootstrap.run(config); + + runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp())); } diff --git a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart new file mode 100644 index 0000000..c4f331b --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; + +import '../routing/app_routes.dart'; +import 'navigation_target.dart'; + +/// App-level navigation helpers for cross-feature handoffs. +/// +/// All navigation logic lives here in the app layer so that feature packages +/// never depend on each other's routes. Feature pages receive plain +/// `void Function(String)` callbacks that they invoke with a SKU or ID; +/// the wiring in [AppRoutes.onGenerateRoute] closes over these helpers. +abstract final class AppNavigation { + // ── Generic ─────────────────────────────────────────────────────────── + + /// Navigate to a [NavigationTarget], replacing the current route. + static void navigateTo(BuildContext context, NavigationTarget target) { + Navigator.of(context).pushReplacementNamed(target.route, arguments: target.arguments); + } + + // ── Dashboard → feature handoffs ────────────────────────────────────── + + /// Dashboard KPI "Total Products" / "In Stock" → Inventory page. + static void dashboardToInventory(BuildContext context, {String? filter, String? query}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter, 'query': ?query}), + ); + } + + /// Dashboard KPI "Draft" → Products page. + static void dashboardToProducts(BuildContext context, {String? filter, String? query}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter, 'query': ?query}), + ); + } + + /// Dashboard KPI "Total Orders" / "Pending" / "Active" → Orders page. + static void dashboardToOrders(BuildContext context, {String? filter, String? query}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter, 'query': ?query}), + ); + } + + /// Dashboard KPI "Revenue" → Finance page. + static void dashboardToFinance(BuildContext context) { + navigateTo(context, NavigationTarget(route: AppRoutes.finance)); + } + + // ── Policy detail → operational page handoffs ───────────────────────── + + /// Policy detail action → related operational page based on category. + static void policyToRelatedPage(BuildContext context, {required String category}) { + final route = _categoryToRoute(category); + navigateTo( + context, + NavigationTarget(route: route, arguments: {'fromPolicy': 'true', 'category': category}), + ); + } + + // ── Orders → Products / Inventory ───────────────────────────────────── + + /// Order line-item SKU → Products page, pre-selecting that SKU. + static void orderToProduct(BuildContext context, {required String sku}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.products, arguments: {'selectedSku': sku}), + ); + } + + /// Order line-item SKU → Inventory page, pre-selecting that SKU. + static void orderToInventory(BuildContext context, {required String sku}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.inventory, arguments: {'selectedSku': sku}), + ); + } + + // ── Products → Policy ───────────────────────────────────────────────── + + /// Product draft → Policy page filtered to "Product Compliance". + static void productToPolicy(BuildContext context) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.policy, arguments: {'category': 'Product Compliance'}), + ); + } + + // ── Inventory → Products ────────────────────────────────────────────── + + /// Inventory item → Products page, pre-selecting by SKU. + static void inventoryToProduct(BuildContext context, {required String sku}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.products, arguments: {'selectedSku': sku}), + ); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + static String _categoryToRoute(String category) { + switch (category) { + case 'Product Compliance': + return AppRoutes.products; + case 'Inventory Governance': + return AppRoutes.inventory; + case 'Order Operations': + return AppRoutes.orders; + case 'Finance & Tax': + return AppRoutes.finance; + case 'Customer Policy': + return AppRoutes.orders; + default: + return AppRoutes.dashboard; + } + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart b/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart new file mode 100644 index 0000000..fe18a23 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart @@ -0,0 +1,64 @@ +/// A lightweight value object describing a cross-feature navigation handoff. +/// +/// Carries the destination route and optional arguments (e.g. a selected-item +/// ID or a preset filter) so the target page can open in the right state. +/// +/// This lives in the app layer (`kell_web`) and is never imported by feature +/// packages, keeping package boundaries strict. +class NavigationTarget { + /// The route path to navigate to (one of [AppRoutes] constants). + final String route; + + /// Optional key-value arguments for the destination page. + /// + /// Common keys: + /// - `selectedSku` – pre-select an item by SKU on the target page + /// - `selectedId` – pre-select an item by ID on the target page + /// - `filter` – apply a preset filter (e.g. `'lowStock'`) + /// - `category` – filter by policy category + final Map arguments; + + const NavigationTarget({required this.route, this.arguments = const {}}); + + /// Convenience: the selected SKU, if any. + String? get selectedSku => arguments['selectedSku']; + + /// Convenience: the selected ID, if any. + String? get selectedId => arguments['selectedId']; + + /// Convenience: a preset filter, if any. + String? get filter => arguments['filter']; + + /// Convenience: a category filter, if any. + String? get category => arguments['category']; + + /// Convenience: a search query, if any. + String? get query => arguments['query']; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NavigationTarget && route == other.route && _mapsEqual(arguments, other.arguments); + + @override + int get hashCode { + // Build a stable hash from sorted keys to ensure consistency. + var argsHash = 0; + final sortedKeys = arguments.keys.toList()..sort(); + for (final key in sortedKeys) { + argsHash = argsHash ^ Object.hash(key, arguments[key]); + } + return Object.hash(route, argsHash); + } + + @override + String toString() => 'NavigationTarget(route: $route, arguments: $arguments)'; + + static bool _mapsEqual(Map a, Map b) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (a[key] != b[key]) return false; + } + return true; + } +} 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..31c6962 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/dashboard_page.dart @@ -0,0 +1,224 @@ +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 '../navigation/app_navigation.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 aggregated summary data. +/// +/// 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; + + const DashboardPage({super.key, required this.controller}); + + @override + State createState() => _DashboardPageState(); +} + +class _DashboardPageState extends State { + @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, summary), + 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, DashboardSummary summary) { + 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 = [ + SummaryCard( + icon: Icons.inventory_2, + iconColor: KcColors.denimBlue, + label: 'Total Products', + value: '${summary.totalProducts}', + onTap: () => AppNavigation.dashboardToInventory(context), + ), + SummaryCard( + icon: Icons.check_circle_outline, + iconColor: KcColors.success, + label: 'In Stock', + value: '${summary.inStock}', + onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'), + ), + SummaryCard( + icon: Icons.warning_amber_rounded, + iconColor: KcColors.warning, + label: 'Low Stock', + value: '${summary.lowStock}', + onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'), + ), + SummaryCard( + icon: Icons.edit_note, + iconColor: KcColors.neutral, + label: 'Draft', + value: '${summary.draftProducts}', + onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'), + ), + SummaryCard( + icon: Icons.receipt_long, + iconColor: KcColors.denimBlue, + label: 'Total Orders', + value: '${summary.totalOrders}', + onTap: () => AppNavigation.dashboardToOrders(context), + ), + SummaryCard( + icon: Icons.hourglass_empty, + iconColor: KcColors.warning, + label: 'Pending Orders', + value: '${summary.pendingOrders}', + onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'), + ), + SummaryCard( + icon: Icons.local_shipping_outlined, + iconColor: KcColors.success, + label: 'Active Orders', + value: '${summary.activeOrders}', + onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'), + ), + SummaryCard( + icon: Icons.attach_money, + iconColor: KcColors.success, + label: 'Revenue', + value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}', + onTap: () => AppNavigation.dashboardToFinance(context), + ), + ]; + + 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/pages/dashboard_placeholder_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/dashboard_placeholder_page.dart new file mode 100644 index 0000000..c144277 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/dashboard_placeholder_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class DashboardPlaceholderPage extends StatelessWidget { + const DashboardPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: Text('Dashboard page coming soon')); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/pages/finance_placeholder_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/finance_placeholder_page.dart new file mode 100644 index 0000000..d132797 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/finance_placeholder_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class FinancePlaceholderPage extends StatelessWidget { + const FinancePlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: Text('Finance page coming soon')); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/pages/integrations_placeholder_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/integrations_placeholder_page.dart new file mode 100644 index 0000000..5c05217 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/integrations_placeholder_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class IntegrationsPlaceholderPage extends StatelessWidget { + const IntegrationsPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: Text('Integrations page coming soon')); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/pages/orders_placeholder_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/orders_placeholder_page.dart new file mode 100644 index 0000000..268cc3e --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/orders_placeholder_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class OrdersPlaceholderPage extends StatelessWidget { + const OrdersPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: Text('Orders page coming soon')); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/pages/policy_placeholder_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/policy_placeholder_page.dart new file mode 100644 index 0000000..32a1712 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/policy_placeholder_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class PolicyPlaceholderPage extends StatelessWidget { + const PolicyPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: Text('Policy page coming soon')); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/pages/products_placeholder_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/products_placeholder_page.dart new file mode 100644 index 0000000..3b64885 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/products_placeholder_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class ProductsPlaceholderPage extends StatelessWidget { + const ProductsPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: Text('Products page coming soon')); + } +} 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 new file mode 100644 index 0000000..c8c9c84 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -0,0 +1,157 @@ +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_policy/feature_policy.dart'; +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 '../navigation/app_navigation.dart'; +import '../pages/dashboard_page.dart'; +import '../pages/finance_placeholder_page.dart'; +import '../pages/integrations_placeholder_page.dart'; +import '../shell/app_shell.dart'; + +abstract final class AppRoutes { + static const String dashboard = '/'; + static const String inventory = '/inventory'; + static const String products = '/products'; + static const String orders = '/orders'; + static const String finance = '/finance'; + static const String policy = '/policy'; + static const String integrations = '/integrations'; + + static Route onGenerateRoute(RouteSettings settings) { + // Extract navigation arguments passed via NavigationTarget. + final args = _extractArgs(settings); + + switch (settings.name) { + case dashboard: + 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, + (context) => AppShell( + selectedRoute: inventory, + title: 'Inventory', + child: InventoryPage( + repository: AppScope.of(context).inventoryRepository, + onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku), + initialFilter: args['filter'], + initialQuery: args['query'], + initialSelectedSku: args['selectedSku'], + ), + ), + ); + case products: + return _buildRoute( + settings, + (context) => AppShell( + selectedRoute: products, + title: 'Products', + child: ProductPublishingPage( + repository: AppScope.of(context).productPublishingRepository, + onViewPolicy: () => AppNavigation.productToPolicy(context), + initialFilter: args['filter'], + initialQuery: args['query'], + initialSelectedSku: args['selectedSku'], + ), + ), + ); + case orders: + return _buildRoute( + settings, + (context) => AppShell( + selectedRoute: orders, + title: 'Orders', + child: OrdersPage( + repository: AppScope.of(context).ordersRepository, + onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku), + onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku), + initialFilter: args['filter'], + initialQuery: args['query'], + initialSelectedId: args['selectedId'], + ), + ), + ); + case finance: + return _buildRoute( + settings, + (context) => const AppShell( + selectedRoute: finance, + title: 'Finance', + child: FinancePlaceholderPage(), + ), + ); + case policy: + return _buildRoute( + settings, + (context) => AppShell( + selectedRoute: policy, + title: 'Policy', + child: PolicyPage( + repository: AppScope.of(context).policyRepository, + onViewRelatedPage: (category) => + AppNavigation.policyToRelatedPage(context, category: category), + initialCategory: args['category'], + initialQuery: args['query'], + initialSelectedId: args['selectedId'], + ), + ), + ); + case integrations: + return _buildRoute( + settings, + (context) => const AppShell( + selectedRoute: integrations, + title: 'Integrations', + child: IntegrationsPlaceholderPage(), + ), + ); + default: + return _buildRoute( + settings, + (context) => AppShell( + selectedRoute: inventory, + title: 'Inventory', + child: InventoryPage( + repository: AppScope.of(context).inventoryRepository, + onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku), + ), + ), + ); + } + } + + static MaterialPageRoute _buildRoute( + RouteSettings settings, + Widget Function(BuildContext context) pageBuilder, + ) { + return MaterialPageRoute(settings: settings, builder: pageBuilder); + } + + /// Safely extracts the `Map` arguments from [RouteSettings]. + /// + /// Returns an empty map when no arguments are present or the type doesn't + /// match, so callers can always index safely. + static Map _extractArgs(RouteSettings settings) { + final raw = settings.arguments; + if (raw is Map) return raw; + return const {}; + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/shell/app_nav_item.dart b/kell_creations_apps/apps/kell_web/lib/shell/app_nav_item.dart new file mode 100644 index 0000000..e69de29 diff --git a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart new file mode 100644 index 0000000..5225cc0 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +import '../composition/app_config.dart'; +import '../composition/app_scope.dart'; +import '../routing/app_routes.dart'; + +class AppShell extends StatelessWidget { + final String selectedRoute; + final String title; + final Widget child; + + const AppShell({ + super.key, + required this.selectedRoute, + required this.title, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final index = _routeToIndex(selectedRoute); + final config = AppScope.configOf(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Kell Creations'), + actions: [ + _EnvironmentBadge(environment: config.environment), + const SizedBox(width: 12), + ], + ), + body: Row( + children: [ + NavigationRail( + selectedIndex: index, + onDestinationSelected: (selectedIndex) { + final route = _indexToRoute(selectedIndex); + if (route != selectedRoute) { + Navigator.of(context).pushReplacementNamed(route); + } + }, + labelType: NavigationRailLabelType.all, + minWidth: 88, + minExtendedWidth: 200, + leading: const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Icon(Icons.storefront_outlined, size: 32), + ), + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: Text('Dashboard'), + ), + NavigationRailDestination( + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), + label: Text('Inventory'), + ), + NavigationRailDestination( + icon: Icon(Icons.sell_outlined), + selectedIcon: Icon(Icons.sell), + label: Text('Products'), + ), + NavigationRailDestination( + icon: Icon(Icons.receipt_long_outlined), + selectedIcon: Icon(Icons.receipt_long), + label: Text('Orders'), + ), + NavigationRailDestination( + icon: Icon(Icons.attach_money_outlined), + selectedIcon: Icon(Icons.attach_money), + label: Text('Finance'), + ), + NavigationRailDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: Text('Policy'), + ), + NavigationRailDestination( + icon: Icon(Icons.hub_outlined), + selectedIcon: Icon(Icons.hub), + label: Text('Integrations'), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8), + Expanded(child: child), + ], + ), + ), + ), + ], + ), + ); + } + + static int _routeToIndex(String route) { + switch (route) { + case AppRoutes.dashboard: + return 0; + case AppRoutes.inventory: + return 1; + case AppRoutes.products: + return 2; + case AppRoutes.orders: + return 3; + case AppRoutes.finance: + return 4; + case AppRoutes.policy: + return 5; + case AppRoutes.integrations: + return 6; + default: + return 1; + } + } + + static String _indexToRoute(int index) { + switch (index) { + case 0: + return AppRoutes.dashboard; + case 1: + return AppRoutes.inventory; + case 2: + return AppRoutes.products; + case 3: + return AppRoutes.orders; + case 4: + return AppRoutes.finance; + case 5: + return AppRoutes.policy; + case 6: + return AppRoutes.integrations; + default: + return AppRoutes.inventory; + } + } +} + +/// A small coloured chip displayed in the [AppBar] that shows the current +/// runtime environment (e.g. "FAKE" or "WP"). +class _EnvironmentBadge extends StatelessWidget { + final AppEnvironment environment; + + const _EnvironmentBadge({required this.environment}); + + @override + Widget build(BuildContext context) { + final Color backgroundColor; + final Color foregroundColor; + + switch (environment) { + case AppEnvironment.fake: + backgroundColor = Colors.orange.shade100; + foregroundColor = Colors.orange.shade900; + case AppEnvironment.wordpress: + backgroundColor = Colors.green.shade100; + foregroundColor = Colors.green.shade900; + } + + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(4)), + child: Text( + environment.label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ), + ); + } +} 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..8b51dba --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/shell/widgets/summary_card.dart @@ -0,0 +1,52 @@ +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. Optionally tappable via [onTap] to navigate to a +/// related page. +class SummaryCard extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String label; + final String value; + + /// Optional tap handler for cross-feature navigation from dashboard KPIs. + final VoidCallback? onTap; + + const SummaryCard({ + super.key, + required this.icon, + required this.iconColor, + required this.label, + required this.value, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final card = 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)), + ], + ), + ); + + if (onTap == null) return card; + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector(onTap: onTap, child: card), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/pubspec.lock b/kell_creations_apps/apps/kell_web/pubspec.lock index b16dcc8..79fa06e 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.lock +++ b/kell_creations_apps/apps/kell_web/pubspec.lock @@ -78,6 +78,27 @@ packages: relative: true source: path version: "0.0.1" + feature_orders: + dependency: "direct main" + description: + path: "../../packages/feature_orders" + relative: true + source: path + version: "0.0.1" + feature_policy: + dependency: "direct main" + description: + path: "../../packages/feature_policy" + relative: true + source: path + version: "0.0.1" + feature_wordpress: + dependency: "direct main" + description: + path: "../../packages/feature_wordpress" + relative: true + source: path + version: "0.0.1" flutter: dependency: "direct main" description: flutter @@ -96,6 +117,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -213,6 +250,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -229,6 +274,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.11.4 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/kell_creations_apps/apps/kell_web/pubspec.yaml b/kell_creations_apps/apps/kell_web/pubspec.yaml index 3e7837f..1d9050e 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.yaml +++ b/kell_creations_apps/apps/kell_web/pubspec.yaml @@ -41,6 +41,12 @@ dependencies: path: ../../packages/design_system feature_inventory: path: ../../packages/feature_inventory + feature_orders: + path: ../../packages/feature_orders + feature_policy: + path: ../../packages/feature_policy + feature_wordpress: + path: ../../packages/feature_wordpress dev_dependencies: flutter_test: diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart new file mode 100644 index 0000000..e8f8298 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart @@ -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 items; + _StubInventoryRepository([this.items = const []]); + + @override + Future> getInventoryItems() async => items; +} + +class _StubProductPublishingRepository implements ProductPublishingRepository { + final List drafts; + _StubProductPublishingRepository([this.drafts = const []]); + + @override + Future> getProductDrafts() async => drafts; + + @override + Future publishDraft(String id) => throw UnimplementedError(); +} + +class _StubOrdersRepository implements OrdersRepository { + final List orders; + _StubOrdersRepository([this.orders = const []]); + + @override + Future> getOrders() async => orders; +} + +class _FailingInventoryRepository implements InventoryRepository { + @override + Future> 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 = []; + 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); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart new file mode 100644 index 0000000..3b4ce7d --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart @@ -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)); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart b/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart new file mode 100644 index 0000000..a565d1e --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kell_web/navigation/navigation_target.dart'; + +void main() { + group('NavigationTarget', () { + test('creates with route and empty arguments by default', () { + const target = NavigationTarget(route: '/inventory'); + expect(target.route, '/inventory'); + expect(target.arguments, isEmpty); + }); + + test('creates with route and arguments', () { + const target = NavigationTarget(route: '/products', arguments: {'selectedSku': 'ABC-123'}); + expect(target.route, '/products'); + expect(target.arguments, {'selectedSku': 'ABC-123'}); + }); + + test('convenience getters return correct values', () { + const target = NavigationTarget( + route: '/inventory', + arguments: { + 'selectedSku': 'SKU-001', + 'selectedId': 'ID-001', + 'filter': 'lowStock', + 'category': 'Product Compliance', + 'query': 'coaster', + }, + ); + expect(target.selectedSku, 'SKU-001'); + expect(target.selectedId, 'ID-001'); + expect(target.filter, 'lowStock'); + expect(target.category, 'Product Compliance'); + expect(target.query, 'coaster'); + }); + + test('convenience getters return null when key is absent', () { + const target = NavigationTarget(route: '/orders'); + expect(target.selectedSku, isNull); + expect(target.selectedId, isNull); + expect(target.filter, isNull); + expect(target.category, isNull); + expect(target.query, isNull); + }); + + test('equality works for same route and arguments', () { + const a = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'}); + const b = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'}); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('inequality for different routes', () { + const a = NavigationTarget(route: '/products'); + const b = NavigationTarget(route: '/orders'); + expect(a, isNot(equals(b))); + }); + + test('inequality for different arguments', () { + const a = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'}); + const b = NavigationTarget(route: '/products', arguments: {'selectedSku': 'Y'}); + expect(a, isNot(equals(b))); + }); + + test('toString includes route and arguments', () { + const target = NavigationTarget(route: '/inventory', arguments: {'filter': 'lowStock'}); + expect(target.toString(), contains('/inventory')); + expect(target.toString(), contains('lowStock')); + }); + + test('query argument round-trips through convenience getter', () { + const target = NavigationTarget(route: '/inventory', arguments: {'query': 'bowl cozy'}); + expect(target.query, 'bowl cozy'); + }); + }); +} 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 cc76849..cb373f8 100644 --- a/kell_creations_apps/apps/kell_web/test/widget_test.dart +++ b/kell_creations_apps/apps/kell_web/test/widget_test.dart @@ -1,30 +1,77 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:kell_web/app.dart'; +import 'package:kell_web/composition/app_config.dart'; +import 'package:kell_web/composition/app_scope.dart'; +import 'package:kell_web/composition/app_services.dart'; -import 'package:kell_web/main.dart'; +Widget _buildTestApp() { + const config = AppConfig( + environment: AppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp()); +} void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('app shell loads dashboard route', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + expect(find.text('Kell Creations'), findsOneWidget); + expect(find.text('Dashboard'), findsWidgets); + }); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + testWidgets('dashboard shows summary cards', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + 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(_buildTestApp()); + 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(_buildTestApp()); + 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); + }); + + testWidgets('environment badge shows FAKE in fake mode', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); + + expect(find.text('FAKE'), findsOneWidget); }); } diff --git a/kell_creations_apps/packages/design_system/lib/design_system.dart b/kell_creations_apps/packages/design_system/lib/design_system.dart index 298576d..269b6f5 100644 --- a/kell_creations_apps/packages/design_system/lib/design_system.dart +++ b/kell_creations_apps/packages/design_system/lib/design_system.dart @@ -1,5 +1,7 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +library; + +export 'src/theme/kc_colors.dart'; +export 'src/theme/kc_spacing.dart'; +export 'src/theme/kc_theme.dart'; +export 'src/widgets/kc_card.dart'; +export 'src/widgets/kc_status_chip.dart'; diff --git a/kell_creations_apps/packages/design_system/lib/src/theme/kc_colors.dart b/kell_creations_apps/packages/design_system/lib/src/theme/kc_colors.dart new file mode 100644 index 0000000..2fcc09b --- /dev/null +++ b/kell_creations_apps/packages/design_system/lib/src/theme/kc_colors.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +abstract final class KcColors { + static const skyBlue = Color(0xFF55DDE0); + static const denimBlue = Color(0xFF33658A); + static const deepTeal = Color(0xFF2F4858); + static const honeyGold = Color(0xFFF6AE2D); + static const sunsetOrange = Color(0xFFF26419); + + static const background = Color(0xFFF8FBFC); + static const surface = Colors.white; + static const border = Color(0xFFD9E4EA); + static const success = Color(0xFF2E7D32); + static const warning = Color(0xFFF9A825); + static const danger = Color(0xFFC62828); + static const neutral = Color(0xFF607D8B); +} diff --git a/kell_creations_apps/packages/design_system/lib/src/theme/kc_spacing.dart b/kell_creations_apps/packages/design_system/lib/src/theme/kc_spacing.dart new file mode 100644 index 0000000..653e121 --- /dev/null +++ b/kell_creations_apps/packages/design_system/lib/src/theme/kc_spacing.dart @@ -0,0 +1,7 @@ +abstract final class KcSpacing { + static const xs = 4.0; + static const sm = 8.0; + static const md = 16.0; + static const lg = 24.0; + static const xl = 32.0; +} diff --git a/kell_creations_apps/packages/design_system/lib/src/theme/kc_theme.dart b/kell_creations_apps/packages/design_system/lib/src/theme/kc_theme.dart new file mode 100644 index 0000000..82b5cab --- /dev/null +++ b/kell_creations_apps/packages/design_system/lib/src/theme/kc_theme.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'kc_colors.dart'; + +ThemeData buildKcTheme() { + final base = ThemeData(useMaterial3: true); + + return base.copyWith( + scaffoldBackgroundColor: KcColors.background, + colorScheme: ColorScheme.fromSeed( + seedColor: KcColors.denimBlue, + primary: KcColors.denimBlue, + secondary: KcColors.skyBlue, + surface: KcColors.surface, + ), + appBarTheme: const AppBarTheme( + backgroundColor: KcColors.surface, + foregroundColor: KcColors.deepTeal, + elevation: 0, + centerTitle: false, + ), + cardTheme: const CardThemeData( + color: KcColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + ), + textTheme: base.textTheme.copyWith( + headlineMedium: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: KcColors.deepTeal, + ), + titleLarge: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: KcColors.deepTeal, + ), + bodyLarge: const TextStyle( + fontSize: 16, + color: KcColors.deepTeal, + ), + bodyMedium: const TextStyle( + fontSize: 14, + color: KcColors.deepTeal, + ), + ), + ); +} \ No newline at end of file diff --git a/kell_creations_apps/packages/design_system/lib/src/widgets/kc_card.dart b/kell_creations_apps/packages/design_system/lib/src/widgets/kc_card.dart new file mode 100644 index 0000000..2ea1340 --- /dev/null +++ b/kell_creations_apps/packages/design_system/lib/src/widgets/kc_card.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import '../theme/kc_colors.dart'; +import '../theme/kc_spacing.dart'; + +class KcCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + + const KcCard({super.key, required this.child, this.padding}); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding ?? const EdgeInsets.all(KcSpacing.md), + decoration: BoxDecoration( + color: KcColors.surface, + border: Border.all(color: KcColors.border), + borderRadius: BorderRadius.circular(16), + boxShadow: const [BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000))], + ), + child: child, + ); + } +} diff --git a/kell_creations_apps/packages/design_system/lib/src/widgets/kc_status_chip.dart b/kell_creations_apps/packages/design_system/lib/src/widgets/kc_status_chip.dart new file mode 100644 index 0000000..232bd74 --- /dev/null +++ b/kell_creations_apps/packages/design_system/lib/src/widgets/kc_status_chip.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class KcStatusChip extends StatelessWidget { + final String label; + final Color background; + final Color foreground; + + const KcStatusChip({ + super.key, + required this.label, + required this.background, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(999)), + child: Text( + label, + style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 12), + ), + ); + } +} diff --git a/kell_creations_apps/packages/design_system/test/design_system_test.dart b/kell_creations_apps/packages/design_system/test/design_system_test.dart index c2beb8d..e1e608e 100644 --- a/kell_creations_apps/packages/design_system/test/design_system_test.dart +++ b/kell_creations_apps/packages/design_system/test/design_system_test.dart @@ -1,12 +1,39 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:design_system/design_system.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + group('KcStatusChip', () { + testWidgets('renders label text', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: KcStatusChip(label: 'Active', background: Colors.green, foreground: Colors.white), + ), + ), + ); + + expect(find.text('Active'), 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()); + }); }); } diff --git a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart index 298576d..a71cdbe 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart @@ -1,5 +1,7 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +library; + +export 'src/data/fake_inventory_repository.dart'; +export 'src/domain/inventory_item.dart'; +export 'src/domain/inventory_repository.dart'; +export 'src/domain/inventory_status.dart'; +export 'src/presentation/inventory_page.dart'; diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/get_inventory_items.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/get_inventory_items.dart new file mode 100644 index 0000000..c8b2f22 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/get_inventory_items.dart @@ -0,0 +1,10 @@ +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; + +class GetInventoryItems { + final InventoryRepository repository; + + GetInventoryItems(this.repository); + + Future> call() => repository.getInventoryItems(); +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart new file mode 100644 index 0000000..65047b2 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart @@ -0,0 +1,123 @@ +import 'package:flutter/foundation.dart'; +import '../domain/inventory_item.dart'; +import '../domain/inventory_status.dart'; +import 'get_inventory_items.dart'; + +/// Controller that manages the inventory workspace state, including +/// filtering by status, free-text search, and item selection. +class InventoryController extends ChangeNotifier { + final GetInventoryItems _getInventoryItems; + + InventoryController(this._getInventoryItems); + + bool isLoading = false; + Object? error; + + /// All items returned by the repository (unfiltered). + List _allItems = []; + + /// The currently visible items after applying [activeFilter] and [searchQuery]. + List items = []; + + /// The currently selected item, if any. + InventoryItem? selectedItem; + + /// The active status filter label, or `null` for "all". + /// + /// Recognised values: `'inStock'`, `'lowStock'`, `'outOfStock'`, `'draft'`. + String? activeFilter; + + /// The current free-text search query applied to name / SKU. + String searchQuery = ''; + + /// Loads all inventory items and applies any current filter / search. + Future load() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + _allItems = await _getInventoryItems(); + _applyFilters(); + } catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } + + /// Sets the status filter and recomputes the visible list. + void setFilter(String? filter) { + activeFilter = filter; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + + /// Selects an item for detail view / highlight. + void selectItem(InventoryItem item) { + selectedItem = item; + notifyListeners(); + } + + /// Attempts to select an item by SKU. Returns `true` if found. + bool selectBySku(String sku) { + final match = _allItems.where((i) => i.sku == sku).firstOrNull; + if (match != null) { + selectedItem = match; + notifyListeners(); + return true; + } + return false; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allItems; + + // Status filter + final status = _parseStatus(activeFilter); + if (status != null) { + result = result.where((i) => i.status == status).toList(); + } + + // Free-text search on name and SKU + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((i) { + return i.name.toLowerCase().contains(q) || i.sku.toLowerCase().contains(q); + }).toList(); + } + + items = result; + + // Keep selection valid; clear if the selected item is no longer visible. + if (selectedItem != null && !items.contains(selectedItem)) { + selectedItem = null; + } + } + + static InventoryStatus? _parseStatus(String? filter) { + if (filter == null) return null; + switch (filter) { + case 'inStock': + return InventoryStatus.inStock; + case 'lowStock': + return InventoryStatus.lowStock; + case 'outOfStock': + return InventoryStatus.outOfStock; + case 'draft': + return InventoryStatus.draft; + default: + return null; + } + } +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart b/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart new file mode 100644 index 0000000..9d09ec7 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart @@ -0,0 +1,61 @@ +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; +import '../domain/inventory_status.dart'; + +class FakeInventoryRepository implements InventoryRepository { + @override + Future> getInventoryItems() async { + await Future.delayed(const Duration(milliseconds: 300)); + + return 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, + ), + ]; + } +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart new file mode 100644 index 0000000..5e9e393 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart @@ -0,0 +1,19 @@ +import 'inventory_status.dart'; + +class InventoryItem { + final String id; + final String sku; + final String name; + final int quantityOnHand; + final double unitPrice; + final InventoryStatus status; + + const InventoryItem({ + required this.id, + required this.sku, + required this.name, + required this.quantityOnHand, + required this.unitPrice, + required this.status, + }); +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart new file mode 100644 index 0000000..f5c716f --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart @@ -0,0 +1,5 @@ +import 'inventory_item.dart'; + +abstract class InventoryRepository { + Future> getInventoryItems(); +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_status.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_status.dart new file mode 100644 index 0000000..964c65f --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_status.dart @@ -0,0 +1 @@ +enum InventoryStatus { inStock, lowStock, outOfStock, draft } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart new file mode 100644 index 0000000..fab2842 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart @@ -0,0 +1,115 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../application/get_inventory_items.dart'; +import '../application/inventory_controller.dart'; +import '../domain/inventory_repository.dart'; +import 'widgets/inventory_item_card.dart'; + +class InventoryPage extends StatefulWidget { + final InventoryRepository repository; + + /// Optional callback to navigate to the Products page for a given SKU. + /// Provided by the app layer to enable cross-feature handoffs. + final void Function(String sku)? onViewProduct; + + /// Optional initial status filter to apply on first load (e.g. `'lowStock'`). + final String? initialFilter; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional SKU to pre-select on first load (from a navigation handoff). + final String? initialSelectedSku; + + const InventoryPage({ + super.key, + required this.repository, + this.onViewProduct, + this.initialFilter, + this.initialQuery, + this.initialSelectedSku, + }); + + @override + State createState() => _InventoryPageState(); +} + +class _InventoryPageState extends State { + late final InventoryController controller; + + @override + void initState() { + super.initState(); + controller = InventoryController(GetInventoryItems(widget.repository)); + + // Apply any initial filter / query before loading. + if (widget.initialFilter != null) { + controller.activeFilter = widget.initialFilter; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by SKU if requested. + if (widget.initialSelectedSku != null) { + controller.selectBySku(widget.initialSelectedSku!); + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null) { + return const Center(child: Text('Failed to load inventory data.')); + } + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + + int crossAxisCount = 1; + if (width >= 1200) { + crossAxisCount = 3; + } else if (width >= 700) { + crossAxisCount = 2; + } + + return GridView.builder( + itemCount: controller.items.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: KcSpacing.md, + mainAxisSpacing: KcSpacing.md, + childAspectRatio: 1.5, + ), + itemBuilder: (context, index) { + final item = controller.items[index]; + return InventoryItemCard( + item: item, + onViewProduct: widget.onViewProduct != null + ? () => widget.onViewProduct!(item.sku) + : null, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/presentation/widgets/inventory_item_card.dart b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/widgets/inventory_item_card.dart new file mode 100644 index 0000000..9f3ab5e --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/widgets/inventory_item_card.dart @@ -0,0 +1,61 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../../domain/inventory_item.dart'; +import '../../domain/inventory_status.dart'; + +class InventoryItemCard extends StatelessWidget { + final InventoryItem item; + + /// Optional callback to navigate to the Products page for this item's SKU. + final VoidCallback? onViewProduct; + + const InventoryItemCard({super.key, required this.item, this.onViewProduct}); + + @override + Widget build(BuildContext context) { + final (label, bg, fg) = _statusStyle(item.status); + + return KcCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.name, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + Text('SKU: ${item.sku}'), + const SizedBox(height: KcSpacing.md), + KcStatusChip(label: label, background: bg, foreground: fg), + const SizedBox(height: KcSpacing.md), + Text('Quantity on hand: ${item.quantityOnHand}'), + const SizedBox(height: KcSpacing.sm), + Text('Unit price: \$${item.unitPrice.toStringAsFixed(2)}'), + if (onViewProduct != null) ...[ + const SizedBox(height: KcSpacing.sm), + GestureDetector( + onTap: onViewProduct, + child: Text( + 'View Product →', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ); + } + + (String, Color, Color) _statusStyle(InventoryStatus status) { + switch (status) { + case InventoryStatus.inStock: + return ('In stock', const Color(0xFFE8F5E9), KcColors.success); + case InventoryStatus.lowStock: + return ('Low stock', const Color(0xFFFFF8E1), KcColors.warning); + case InventoryStatus.outOfStock: + return ('Out of stock', const Color(0xFFFFEBEE), KcColors.danger); + case InventoryStatus.draft: + return ('Draft', const Color(0xFFECEFF1), KcColors.neutral); + } + } +} diff --git a/kell_creations_apps/packages/feature_inventory/pubspec.yaml b/kell_creations_apps/packages/feature_inventory/pubspec.yaml index dd0625e..4a9df9b 100644 --- a/kell_creations_apps/packages/feature_inventory/pubspec.yaml +++ b/kell_creations_apps/packages/feature_inventory/pubspec.yaml @@ -1,6 +1,7 @@ name: feature_inventory description: "A new Flutter package project." version: 0.0.1 +publish_to: "none" homepage: environment: @@ -10,6 +11,8 @@ environment: dependencies: flutter: sdk: flutter + design_system: + path: ../design_system dev_dependencies: flutter_test: diff --git a/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart b/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart index 81f9c03..fbd261a 100644 --- a/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart +++ b/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart @@ -1,12 +1,159 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_inventory/src/application/get_inventory_items.dart'; +import 'package:feature_inventory/src/application/inventory_controller.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + group('InventoryStatus', () { + test('has four values', () { + expect(InventoryStatus.values.length, 4); + }); + + 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')); + }); + }); + + group('InventoryController', () { + late FakeInventoryRepository repository; + late InventoryController controller; + + setUp(() { + repository = FakeInventoryRepository(); + controller = InventoryController(GetInventoryItems(repository)); + }); + + tearDown(() { + controller.dispose(); + }); + + test('starts with empty state', () { + expect(controller.isLoading, false); + expect(controller.items, isEmpty); + expect(controller.selectedItem, isNull); + expect(controller.activeFilter, isNull); + expect(controller.searchQuery, ''); + expect(controller.error, isNull); + }); + + test('load populates items', () async { + await controller.load(); + + expect(controller.isLoading, false); + expect(controller.items.length, 6); + expect(controller.error, isNull); + }); + + test('setFilter filters by status', () async { + await controller.load(); + + controller.setFilter('lowStock'); + expect(controller.items.length, 2); + expect(controller.items.every((i) => i.status == InventoryStatus.lowStock), true); + }); + + test('setFilter with null shows all items', () async { + await controller.load(); + + controller.setFilter('lowStock'); + expect(controller.items.length, 2); + + controller.setFilter(null); + expect(controller.items.length, 6); + }); + + test('setSearchQuery filters by name', () async { + await controller.load(); + + controller.setSearchQuery('cozy'); + expect(controller.items.length, 1); + expect(controller.items.first.name, 'Floral Bowl Cozy'); + }); + + test('setSearchQuery filters by SKU', () async { + await controller.load(); + + controller.setSearchQuery('BC-FLR'); + expect(controller.items.length, 1); + expect(controller.items.first.sku, 'BC-FLR-001'); + }); + + test('search is case-insensitive', () async { + await controller.load(); + + controller.setSearchQuery('OCEAN'); + expect(controller.items.length, 1); + expect(controller.items.first.name, 'Ocean Nightlight'); + }); + + test('filter and search combine', () async { + await controller.load(); + + controller.setFilter('inStock'); + controller.setSearchQuery('floral'); + expect(controller.items.length, 1); + expect(controller.items.first.name, 'Floral Bowl Cozy'); + }); + + test('selectItem sets selectedItem', () async { + await controller.load(); + + final item = controller.items[2]; + controller.selectItem(item); + expect(controller.selectedItem, item); + }); + + test('selectBySku selects matching item', () async { + await controller.load(); + + final found = controller.selectBySku('CS-CIT-002'); + expect(found, true); + expect(controller.selectedItem!.sku, 'CS-CIT-002'); + }); + + test('selectBySku returns false for unknown SKU', () async { + await controller.load(); + + final found = controller.selectBySku('UNKNOWN'); + expect(found, false); + expect(controller.selectedItem, isNull); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select an inStock item. + controller.selectBySku('BC-FLR-001'); + expect(controller.selectedItem, isNotNull); + + // Filter to lowStock — the selected item should be cleared. + controller.setFilter('lowStock'); + expect(controller.selectedItem, isNull); + }); }); } diff --git a/kell_creations_apps/packages/feature_orders/.dart_tool/package_config.json b/kell_creations_apps/packages/feature_orders/.dart_tool/package_config.json new file mode 100644 index 0000000..c245196 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/.dart_tool/package_config.json @@ -0,0 +1,178 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "async", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/async-2.13.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "characters", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/characters-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "clock", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/clock-1.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "collection", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "design_system", + "rootUri": "../../design_system", + "packageUri": "lib/", + "languageVersion": "3.11" + }, + { + "name": "fake_async", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/fake_async-1.3.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "flutter", + "rootUri": "file:///D:/develop/flutter/packages/flutter", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "flutter_lints", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_lints-6.0.0", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "flutter_test", + "rootUri": "file:///D:/develop/flutter/packages/flutter_test", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "leak_tracker", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker-11.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_flutter_testing", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_testing", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker_testing-3.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "lints", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/lints-6.1.0", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "matcher", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.19", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "material_color_utilities", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/material_color_utilities-0.13.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "meta", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.17.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "path", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "sky_engine", + "rootUri": "file:///D:/develop/flutter/bin/cache/pkg/sky_engine", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "source_span", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "string_scanner", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test_api", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.10", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "vector_math", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "vm_service", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vm_service-15.0.2", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "feature_orders", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.11" + } + ], + "generator": "pub", + "generatorVersion": "3.11.4", + "flutterRoot": "file:///D:/develop/flutter", + "flutterVersion": "3.41.6", + "pubCache": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache" +} diff --git a/kell_creations_apps/packages/feature_orders/.dart_tool/package_graph.json b/kell_creations_apps/packages/feature_orders/.dart_tool/package_graph.json new file mode 100644 index 0000000..f7e1e30 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/.dart_tool/package_graph.json @@ -0,0 +1,232 @@ +{ + "roots": [ + "feature_orders" + ], + "packages": [ + { + "name": "feature_orders", + "version": "0.0.1", + "dependencies": [ + "design_system", + "flutter" + ], + "devDependencies": [ + "flutter_lints", + "flutter_test" + ] + }, + { + "name": "flutter_lints", + "version": "6.0.0", + "dependencies": [ + "lints" + ] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "design_system", + "version": "0.0.1", + "dependencies": [ + "flutter" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "lints", + "version": "6.1.0", + "dependencies": [] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "meta", + "version": "1.17.0", + "dependencies": [] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.19", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "test_api", + "version": "0.7.10", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.13.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.1", + "dependencies": [] + }, + { + "name": "async", + "version": "2.13.1", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "vm_service", + "version": "15.0.2", + "dependencies": [] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/kell_creations_apps/packages/feature_orders/.dart_tool/version b/kell_creations_apps/packages/feature_orders/.dart_tool/version new file mode 100644 index 0000000..0719cf9 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/.dart_tool/version @@ -0,0 +1 @@ +3.41.6 \ No newline at end of file diff --git a/kell_creations_apps/packages/feature_orders/analysis_options.yaml b/kell_creations_apps/packages/feature_orders/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/kell_creations_apps/packages/feature_orders/build/native_assets/windows/native_assets.json b/kell_creations_apps/packages/feature_orders/build/native_assets/windows/native_assets.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/build/native_assets/windows/native_assets.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill b/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill new file mode 100644 index 0000000..d162925 Binary files /dev/null and b/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill differ diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/AssetManifest.bin b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/AssetManifest.bin new file mode 100644 index 0000000..86d111f Binary files /dev/null and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/AssetManifest.bin differ diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/FontManifest.json b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/FontManifest.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/FontManifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z new file mode 100644 index 0000000..817dfc1 Binary files /dev/null and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z differ diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NativeAssetsManifest.json b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NativeAssetsManifest.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NativeAssetsManifest.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag new file mode 100644 index 0000000..794ba24 Binary files /dev/null and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag new file mode 100644 index 0000000..c014a19 Binary files /dev/null and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag differ diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart new file mode 100644 index 0000000..df547cb --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -0,0 +1,8 @@ +library; + +export 'src/data/fake_orders_repository.dart'; +export 'src/domain/order.dart'; +export 'src/domain/order_item.dart'; +export 'src/domain/order_status.dart'; +export 'src/domain/orders_repository.dart'; +export 'src/presentation/orders_page.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/get_orders.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/get_orders.dart new file mode 100644 index 0000000..dd5748f --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/get_orders.dart @@ -0,0 +1,11 @@ +import '../domain/order.dart'; +import '../domain/orders_repository.dart'; + +/// Use case: retrieve all orders from the repository. +class GetOrders { + final OrdersRepository repository; + + GetOrders(this.repository); + + Future> call() => repository.getOrders(); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart new file mode 100644 index 0000000..c151fd3 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; + +import '../domain/order.dart'; +import '../domain/order_status.dart'; +import 'get_orders.dart'; + +/// Controller that manages the orders workspace state, including +/// filtering by order status, free-text search, and order selection. +class OrdersController extends ChangeNotifier { + final GetOrders _getOrders; + + OrdersController(this._getOrders); + + bool isLoading = false; + Object? error; + + /// All orders returned by the repository (unfiltered). + List _allOrders = []; + + /// The currently visible orders after applying [activeFilter] and [searchQuery]. + List orders = []; + + /// The currently selected order for detail view. + Order? selectedOrder; + + /// The active status filter label, or `null` for "all". + /// + /// Recognised values: `'pending'`, `'processing'`, `'shipped'`, + /// `'delivered'`, `'cancelled'`. + String? activeFilter; + + /// The current free-text search query applied to customer name / order ID. + String searchQuery = ''; + + /// Loads all orders and applies any current filter / search. + Future load() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + _allOrders = await _getOrders(); + _applyFilters(); + // Auto-select the first order if nothing is selected. + selectedOrder ??= orders.isNotEmpty ? orders.first : null; + } catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } + + /// Sets the status filter and recomputes the visible list. + void setFilter(String? filter) { + activeFilter = filter; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + + /// Selects an order for detail view. + void selectOrder(Order order) { + selectedOrder = order; + notifyListeners(); + } + + /// Attempts to select an order by ID. Returns `true` if found. + bool selectById(String id) { + final match = _allOrders.where((o) => o.id == id).firstOrNull; + if (match != null) { + selectedOrder = match; + notifyListeners(); + return true; + } + return false; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allOrders; + + // Status filter + final status = _parseStatus(activeFilter); + if (status != null) { + result = result.where((o) => o.status == status).toList(); + } + + // Free-text search on customer name and order ID + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((o) { + return o.customerName.toLowerCase().contains(q) || o.id.toLowerCase().contains(q); + }).toList(); + } + + orders = result; + + // Keep selection valid; clear if the selected order is no longer visible. + if (selectedOrder != null && !orders.any((o) => o.id == selectedOrder!.id)) { + selectedOrder = null; + } + } + + static OrderStatus? _parseStatus(String? filter) { + if (filter == null) return null; + switch (filter) { + case 'pending': + return OrderStatus.pending; + case 'processing': + return OrderStatus.processing; + case 'shipped': + return OrderStatus.shipped; + case 'delivered': + return OrderStatus.delivered; + case 'cancelled': + return OrderStatus.cancelled; + default: + return null; + } + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart new file mode 100644 index 0000000..57f6575 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart @@ -0,0 +1,144 @@ +import '../domain/order.dart'; +import '../domain/order_item.dart'; +import '../domain/order_status.dart'; +import '../domain/orders_repository.dart'; + +/// Stubbed implementation of [OrdersRepository] with sample +/// Kell Creations orders. No real WooCommerce or shipping API calls are made. +class FakeOrdersRepository implements OrdersRepository { + final List _orders = [ + Order( + id: 'KC-1001', + customerName: 'Sarah Mitchell', + customerEmail: 'sarah.mitchell@example.com', + orderDate: DateTime(2026, 4, 1), + status: OrderStatus.delivered, + shippingAddress: '123 Maple St, Asheville, NC 28801', + 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.thornton@example.com', + orderDate: DateTime(2026, 4, 2), + status: OrderStatus.shipped, + shippingAddress: '456 Oak Ave, Knoxville, TN 37902', + items: const [ + OrderItem( + productName: 'Ocean Nightlight', + sku: 'NL-OCN-003', + quantity: 1, + unitPrice: 19.99, + ), + OrderItem( + productName: 'Sublimated Slate Coaster', + sku: 'SC-SUB-006', + quantity: 3, + unitPrice: 14.99, + ), + ], + ), + Order( + id: 'KC-1003', + customerName: 'Emily Chen', + customerEmail: 'emily.chen@example.com', + orderDate: DateTime(2026, 4, 3), + status: OrderStatus.processing, + shippingAddress: '789 Pine Rd, Charlotte, NC 28202', + items: const [ + OrderItem( + productName: 'Fabric Jar Gripper', + sku: 'JG-BLU-004', + quantity: 4, + unitPrice: 8.50, + ), + ], + ), + Order( + id: 'KC-1004', + customerName: 'David Park', + customerEmail: 'david.park@example.com', + orderDate: DateTime(2026, 4, 3), + status: OrderStatus.pending, + shippingAddress: '321 Birch Ln, Greenville, SC 29601', + items: const [ + OrderItem( + productName: 'Skillet Handle Sleeve', + sku: 'SH-SUN-005', + quantity: 2, + unitPrice: 10.99, + ), + OrderItem( + productName: 'Floral Bowl Cozy', + sku: 'BC-FLR-001', + quantity: 1, + unitPrice: 12.99, + ), + ], + ), + Order( + id: 'KC-1005', + customerName: 'Rachel Adams', + customerEmail: 'rachel.adams@example.com', + orderDate: DateTime(2026, 3, 28), + status: OrderStatus.cancelled, + shippingAddress: '654 Elm St, Richmond, VA 23220', + items: const [ + OrderItem( + productName: 'Citrus Coaster Set', + sku: 'CS-CIT-002', + quantity: 2, + unitPrice: 16.50, + ), + ], + ), + Order( + id: 'KC-1006', + customerName: 'Maria Gonzalez', + customerEmail: 'maria.gonzalez@example.com', + orderDate: DateTime(2026, 4, 4), + status: OrderStatus.pending, + shippingAddress: '987 Cedar Dr, Atlanta, GA 30301', + items: const [ + OrderItem( + productName: 'Ocean Nightlight', + sku: 'NL-OCN-003', + quantity: 1, + unitPrice: 19.99, + ), + OrderItem( + productName: 'Fabric Jar Gripper', + sku: 'JG-BLU-004', + quantity: 2, + unitPrice: 8.50, + ), + OrderItem( + productName: 'Sublimated Slate Coaster', + sku: 'SC-SUB-006', + quantity: 1, + unitPrice: 14.99, + ), + ], + ), + ]; + + @override + Future> getOrders() async { + // Simulate network latency. + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_orders); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart new file mode 100644 index 0000000..b9d66bf --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart @@ -0,0 +1,29 @@ +import 'order_item.dart'; +import 'order_status.dart'; + +/// A customer order placed through the Kell Creations store. +class Order { + final String id; + final String customerName; + final String customerEmail; + final DateTime orderDate; + final OrderStatus status; + final List items; + final String shippingAddress; + + const Order({ + required this.id, + required this.customerName, + required this.customerEmail, + required this.orderDate, + required this.status, + required this.items, + required this.shippingAddress, + }); + + /// The total value of the order. + double get total => items.fold(0, (sum, item) => sum + item.lineTotal); + + /// The number of individual items in the order. + int get itemCount => items.fold(0, (sum, item) => sum + item.quantity); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart new file mode 100644 index 0000000..67609dd --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart @@ -0,0 +1,17 @@ +/// A single line item within an [Order]. +class OrderItem { + final String productName; + final String sku; + final int quantity; + final double unitPrice; + + const OrderItem({ + required this.productName, + required this.sku, + required this.quantity, + required this.unitPrice, + }); + + /// The total price for this line item. + double get lineTotal => quantity * unitPrice; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart new file mode 100644 index 0000000..4844ed7 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart @@ -0,0 +1,17 @@ +/// The fulfilment status of a customer order. +enum OrderStatus { + /// Order has been placed but not yet processed. + pending, + + /// Order is being prepared / packed. + processing, + + /// Order has been shipped to the customer. + shipped, + + /// Order has been delivered. + delivered, + + /// Order was cancelled before fulfilment. + cancelled, +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart new file mode 100644 index 0000000..e6be39b --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart @@ -0,0 +1,7 @@ +import 'order.dart'; + +/// Contract for fetching and managing customer orders. +abstract class OrdersRepository { + /// Returns all orders. + Future> getOrders(); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart new file mode 100644 index 0000000..98979db --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart @@ -0,0 +1,139 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../application/get_orders.dart'; +import '../application/orders_controller.dart'; +import '../domain/orders_repository.dart'; +import 'widgets/order_card.dart'; +import 'widgets/order_detail_panel.dart'; + +/// The main Orders page. +/// +/// Displays a list of orders on the left and a detail panel on the right. +/// Users can select an order to view its full details. +class OrdersPage extends StatefulWidget { + final OrdersRepository repository; + + /// Optional callback to navigate to the Products page for a given SKU. + final void Function(String sku)? onViewProduct; + + /// Optional callback to navigate to the Inventory page for a given SKU. + final void Function(String sku)? onViewInventory; + + /// Optional initial status filter to apply on first load (e.g. `'pending'`). + final String? initialFilter; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional order ID to pre-select on first load (from a navigation handoff). + final String? initialSelectedId; + + const OrdersPage({ + super.key, + required this.repository, + this.onViewProduct, + this.onViewInventory, + this.initialFilter, + this.initialQuery, + this.initialSelectedId, + }); + + @override + State createState() => _OrdersPageState(); +} + +class _OrdersPageState extends State { + late final OrdersController controller; + + @override + void initState() { + super.initState(); + controller = OrdersController(GetOrders(widget.repository)); + + // Apply any initial filter / query before loading. + if (widget.initialFilter != null) { + controller.activeFilter = widget.initialFilter; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by ID if requested. + if (widget.initialSelectedId != null) { + controller.selectById(widget.initialSelectedId!); + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null) { + return const Center(child: Text('Failed to load orders.')); + } + + return LayoutBuilder( + builder: (context, constraints) { + // On narrow screens show only the list; on wide screens show + // a master-detail layout. + if (constraints.maxWidth < 800) { + return _buildOrderList(); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 380, child: _buildOrderList()), + const SizedBox(width: KcSpacing.md), + Expanded(child: _buildDetail()), + ], + ); + }, + ); + }, + ); + } + + Widget _buildOrderList() { + return ListView.separated( + itemCount: controller.orders.length, + separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm), + itemBuilder: (context, index) { + final order = controller.orders[index]; + return SizedBox( + height: 160, + child: OrderCard( + order: order, + isSelected: order.id == controller.selectedOrder?.id, + onTap: () => controller.selectOrder(order), + ), + ); + }, + ); + } + + Widget _buildDetail() { + final selected = controller.selectedOrder; + if (selected == null) { + return const Center(child: Text('Select an order to view details')); + } + return OrderDetailPanel( + order: selected, + onViewProduct: widget.onViewProduct, + onViewInventory: widget.onViewInventory, + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_card.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_card.dart new file mode 100644 index 0000000..b8baeec --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_card.dart @@ -0,0 +1,84 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/order.dart'; +import 'order_status_chip.dart'; + +/// A card displaying a summary of an [Order]. +/// +/// Shows the order ID, customer name, date, total, item count, and status. +/// Highlights when [isSelected] is true. +class OrderCard extends StatelessWidget { + final Order order; + final bool isSelected; + final VoidCallback? onTap; + + const OrderCard({super.key, required this.order, this.isSelected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(KcSpacing.md), + decoration: BoxDecoration( + color: KcColors.surface, + border: Border.all( + color: isSelected ? KcColors.denimBlue : KcColors.border, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + order.id, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + OrderStatusChip(status: order.status), + ], + ), + const SizedBox(height: KcSpacing.xs), + Text(order.customerName, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: KcSpacing.sm), + Row( + children: [ + Text( + '\$${order.total.toStringAsFixed(2)}', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(width: KcSpacing.sm), + Text( + '${order.itemCount} item${order.itemCount == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral), + ), + ], + ), + const Spacer(), + Text( + _formatDate(order.orderDate), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), + ), + ], + ), + ), + ); + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart new file mode 100644 index 0000000..45505d8 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart @@ -0,0 +1,182 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/order.dart'; +import 'order_status_chip.dart'; + +/// A detail panel that shows the full information for the selected [Order]. +/// +/// Includes customer info, shipping address, line items table, and order total. +class OrderDetailPanel extends StatelessWidget { + final Order order; + + /// Optional callback to navigate to the Products page for a given SKU. + final void Function(String sku)? onViewProduct; + + /// Optional callback to navigate to the Inventory page for a given SKU. + final void Function(String sku)? onViewInventory; + + const OrderDetailPanel({ + super.key, + required this.order, + this.onViewProduct, + this.onViewInventory, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KcCard( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ───────────────────────────────────────────────── + Row( + children: [ + Expanded(child: Text('Order ${order.id}', style: theme.textTheme.headlineMedium)), + const SizedBox(width: KcSpacing.sm), + OrderStatusChip(status: order.status), + ], + ), + const SizedBox(height: KcSpacing.md), + + // ── Customer info ────────────────────────────────────────── + Text('Customer', style: theme.textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + _MetadataRow(label: 'Name', value: order.customerName), + _MetadataRow(label: 'Email', value: order.customerEmail), + _MetadataRow(label: 'Order Date', value: _formatDate(order.orderDate)), + const SizedBox(height: KcSpacing.md), + + // ── Shipping address ─────────────────────────────────────── + Text('Shipping Address', style: theme.textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + Text(order.shippingAddress, style: theme.textTheme.bodyLarge), + const SizedBox(height: KcSpacing.md), + + // ── Line items ───────────────────────────────────────────── + Text('Items', style: theme.textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + ...order.items.map( + (item) => Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.productName, style: theme.textTheme.bodyMedium), + Text( + 'SKU: ${item.sku}', + style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral), + ), + if (onViewProduct != null || onViewInventory != null) + Padding( + padding: const EdgeInsets.only(top: KcSpacing.xs), + child: Wrap( + spacing: KcSpacing.sm, + children: [ + if (onViewProduct != null) + GestureDetector( + onTap: () => onViewProduct!(item.sku), + child: Text( + 'View Product', + style: theme.textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, + ), + ), + ), + if (onViewInventory != null) + GestureDetector( + onTap: () => onViewInventory!(item.sku), + child: Text( + 'View Inventory', + style: theme.textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ), + SizedBox( + width: 40, + child: Text( + 'x${item.quantity}', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: 80, + child: Text( + '\$${item.lineTotal.toStringAsFixed(2)}', + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.end, + ), + ), + ], + ), + ), + ), + const Divider(height: KcSpacing.lg), + + // ── Total ────────────────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('Total: ', style: theme.textTheme.titleLarge), + Text( + '\$${order.total.toStringAsFixed(2)}', + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ], + ), + ), + ); + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} + +class _MetadataRow extends StatelessWidget { + final String label; + final String value; + + const _MetadataRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: KcColors.neutral, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart new file mode 100644 index 0000000..102453e --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/order_status.dart'; + +/// A chip that displays the [OrderStatus] of an order using the +/// design-system [KcStatusChip]. +class OrderStatusChip extends StatelessWidget { + final OrderStatus status; + + const OrderStatusChip({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final (label, bg, fg) = _style(status); + return KcStatusChip(label: label, background: bg, foreground: fg); + } + + static (String, Color, Color) _style(OrderStatus status) { + switch (status) { + case OrderStatus.pending: + return ('Pending', const Color(0xFFFFF8E1), KcColors.warning); + case OrderStatus.processing: + return ('Processing', const Color(0xFFE3F2FD), KcColors.denimBlue); + case OrderStatus.shipped: + return ('Shipped', const Color(0xFFE0F7FA), KcColors.deepTeal); + case OrderStatus.delivered: + return ('Delivered', const Color(0xFFE8F5E9), KcColors.success); + case OrderStatus.cancelled: + return ('Cancelled', const Color(0xFFFFEBEE), KcColors.danger); + } + } +} diff --git a/kell_creations_apps/packages/feature_orders/pubspec.lock b/kell_creations_apps/packages/feature_orders/pubspec.lock new file mode 100644 index 0000000..70c036c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/pubspec.lock @@ -0,0 +1,212 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + design_system: + dependency: "direct main" + description: + path: "../design_system" + relative: true + source: path + version: "0.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" +sdks: + dart: ">=3.11.4 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/kell_creations_apps/packages/feature_orders/pubspec.yaml b/kell_creations_apps/packages/feature_orders/pubspec.yaml new file mode 100644 index 0000000..644cec0 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/pubspec.yaml @@ -0,0 +1,22 @@ +name: feature_orders +description: "Order management feature for Kell Creations." +version: 0.0.1 +publish_to: "none" +homepage: + +environment: + sdk: ^3.11.4 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + design_system: + path: ../design_system + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: diff --git a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart new file mode 100644 index 0000000..3658985 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; + +void main() { + late FakeOrdersRepository repository; + + setUp(() { + repository = FakeOrdersRepository(); + }); + + group('FakeOrdersRepository', () { + test('getOrders returns six sample orders', () async { + final orders = await repository.getOrders(); + expect(orders.length, 6); + }); + + test('getOrders returns orders with expected IDs', () async { + final orders = await repository.getOrders(); + final ids = orders.map((o) => o.id).toList(); + expect(ids, contains('KC-1001')); + expect(ids, contains('KC-1002')); + expect(ids, contains('KC-1003')); + expect(ids, contains('KC-1004')); + expect(ids, contains('KC-1005')); + expect(ids, contains('KC-1006')); + }); + + test('getOrders returns orders with various statuses', () async { + final orders = await repository.getOrders(); + final statuses = orders.map((o) => o.status).toSet(); + expect(statuses, contains(OrderStatus.pending)); + expect(statuses, contains(OrderStatus.processing)); + expect(statuses, contains(OrderStatus.shipped)); + expect(statuses, contains(OrderStatus.delivered)); + expect(statuses, contains(OrderStatus.cancelled)); + }); + + test('order totals are computed correctly', () async { + final orders = await repository.getOrders(); + // KC-1001: 2 * 12.99 + 1 * 16.50 = 42.48 + final order1001 = orders.firstWhere((o) => o.id == 'KC-1001'); + expect(order1001.total, closeTo(42.48, 0.01)); + }); + + test('order item count is computed correctly', () async { + final orders = await repository.getOrders(); + // KC-1001: 2 + 1 = 3 items + final order1001 = orders.firstWhere((o) => o.id == 'KC-1001'); + expect(order1001.itemCount, 3); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart b/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart new file mode 100644 index 0000000..7b65c2c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart @@ -0,0 +1,20 @@ +// This file ensures the barrel export compiles correctly. +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; + +void main() { + test('barrel export exposes Order', () { + // Verify the domain model is accessible through the barrel export. + final order = Order( + id: 'test', + customerName: 'Test', + customerEmail: 'test@test.com', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '123 Test St', + ); + expect(order.id, 'test'); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart new file mode 100644 index 0000000..7aa7106 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_orders/src/application/get_orders.dart'; +import 'package:feature_orders/src/application/orders_controller.dart'; + +void main() { + late FakeOrdersRepository repository; + late OrdersController controller; + + setUp(() { + repository = FakeOrdersRepository(); + controller = OrdersController(GetOrders(repository)); + }); + + tearDown(() { + controller.dispose(); + }); + + group('OrdersController', () { + test('starts with empty state', () { + expect(controller.isLoading, false); + expect(controller.orders, isEmpty); + expect(controller.selectedOrder, isNull); + expect(controller.activeFilter, isNull); + expect(controller.searchQuery, ''); + expect(controller.error, isNull); + }); + + test('load populates orders and auto-selects first', () async { + await controller.load(); + + expect(controller.isLoading, false); + expect(controller.orders.length, 6); + expect(controller.selectedOrder, isNotNull); + expect(controller.selectedOrder!.id, 'KC-1001'); + expect(controller.error, isNull); + }); + + test('selectOrder updates selectedOrder', () async { + await controller.load(); + + final third = controller.orders[2]; + controller.selectOrder(third); + + expect(controller.selectedOrder!.id, third.id); + }); + + test('setFilter filters by order status', () async { + await controller.load(); + + controller.setFilter('pending'); + expect(controller.orders.length, 2); + expect(controller.orders.every((o) => o.status == OrderStatus.pending), true); + }); + + test('setFilter with null shows all orders', () async { + await controller.load(); + + controller.setFilter('cancelled'); + expect(controller.orders.length, 1); + + controller.setFilter(null); + expect(controller.orders.length, 6); + }); + + test('setSearchQuery filters by customer name', () async { + await controller.load(); + + controller.setSearchQuery('sarah'); + expect(controller.orders.length, 1); + expect(controller.orders.first.customerName, 'Sarah Mitchell'); + }); + + test('setSearchQuery filters by order ID', () async { + await controller.load(); + + controller.setSearchQuery('KC-1003'); + expect(controller.orders.length, 1); + expect(controller.orders.first.id, 'KC-1003'); + }); + + test('filter and search combine', () async { + await controller.load(); + + controller.setFilter('pending'); + controller.setSearchQuery('david'); + expect(controller.orders.length, 1); + expect(controller.orders.first.customerName, 'David Park'); + }); + + test('selectById selects matching order', () async { + await controller.load(); + + final found = controller.selectById('KC-1004'); + expect(found, true); + expect(controller.selectedOrder!.id, 'KC-1004'); + }); + + test('selectById returns false for unknown ID', () async { + await controller.load(); + + final found = controller.selectById('UNKNOWN'); + expect(found, false); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select a delivered order. + controller.selectById('KC-1001'); + expect(controller.selectedOrder, isNotNull); + + // Filter to pending — the selected order should be cleared. + controller.setFilter('pending'); + expect(controller.selectedOrder, isNull); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/order_card_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/order_card_test.dart new file mode 100644 index 0000000..1ad2abd --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/order_card_test.dart @@ -0,0 +1,72 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_orders/src/presentation/widgets/order_card.dart'; + +void main() { + final sampleOrder = Order( + id: 'KC-9999', + customerName: 'Test Customer', + customerEmail: 'test@example.com', + orderDate: DateTime(2026, 4, 1), + status: OrderStatus.processing, + shippingAddress: '123 Test St, Test City, TS 00000', + items: const [ + OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 2, unitPrice: 10.00), + ], + ); + + Widget buildTestWidget({bool isSelected = false, VoidCallback? onTap}) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold( + body: SizedBox( + height: 200, + width: 400, + child: OrderCard(order: sampleOrder, isSelected: isSelected, onTap: onTap), + ), + ), + ); + } + + group('OrderCard', () { + testWidgets('displays order ID', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('KC-9999'), findsOneWidget); + }); + + testWidgets('displays customer name', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('Test Customer'), findsOneWidget); + }); + + testWidgets('displays total', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('\$20.00'), findsOneWidget); + }); + + testWidgets('displays item count', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('2 items'), findsOneWidget); + }); + + testWidgets('displays status chip', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('Processing'), findsOneWidget); + }); + + testWidgets('displays date', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('2026-04-01'), findsOneWidget); + }); + + testWidgets('calls onTap when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget(buildTestWidget(onTap: () => tapped = true)); + await tester.tap(find.text('KC-9999')); + expect(tapped, true); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/order_detail_panel_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/order_detail_panel_test.dart new file mode 100644 index 0000000..ab7eabe --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/order_detail_panel_test.dart @@ -0,0 +1,75 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_orders/src/presentation/widgets/order_detail_panel.dart'; + +void main() { + final sampleOrder = Order( + id: 'KC-9999', + customerName: 'Test Customer', + customerEmail: 'test@example.com', + orderDate: DateTime(2026, 4, 1), + status: OrderStatus.shipped, + shippingAddress: '123 Test St, Test City, TS 00000', + items: const [ + OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 2, unitPrice: 10.00), + OrderItem(productName: 'Another Product', sku: 'AP-002', quantity: 1, unitPrice: 15.50), + ], + ); + + Widget buildTestWidget(Order order) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold( + body: SingleChildScrollView(child: OrderDetailPanel(order: order)), + ), + ); + } + + group('OrderDetailPanel', () { + testWidgets('displays order ID in header', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('Order KC-9999'), findsOneWidget); + }); + + testWidgets('displays status chip', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('Shipped'), findsOneWidget); + }); + + testWidgets('displays customer name', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('Test Customer'), findsOneWidget); + }); + + testWidgets('displays customer email', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('test@example.com'), findsOneWidget); + }); + + testWidgets('displays shipping address', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('123 Test St, Test City, TS 00000'), findsOneWidget); + }); + + testWidgets('displays line item product names', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('Test Product'), findsOneWidget); + expect(find.text('Another Product'), findsOneWidget); + }); + + testWidgets('displays line item SKUs', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + expect(find.text('SKU: TP-001'), findsOneWidget); + expect(find.text('SKU: AP-002'), findsOneWidget); + }); + + testWidgets('displays order total', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleOrder)); + // 2 * 10.00 + 1 * 15.50 = 35.50 + expect(find.text('\$35.50'), findsOneWidget); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/order_status_chip_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/order_status_chip_test.dart new file mode 100644 index 0000000..4db39ae --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/order_status_chip_test.dart @@ -0,0 +1,42 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_orders/src/presentation/widgets/order_status_chip.dart'; + +void main() { + Widget buildTestWidget(OrderStatus status) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold(body: OrderStatusChip(status: status)), + ); + } + + group('OrderStatusChip', () { + testWidgets('shows Pending label for pending status', (tester) async { + await tester.pumpWidget(buildTestWidget(OrderStatus.pending)); + expect(find.text('Pending'), findsOneWidget); + }); + + testWidgets('shows Processing label for processing status', (tester) async { + await tester.pumpWidget(buildTestWidget(OrderStatus.processing)); + expect(find.text('Processing'), findsOneWidget); + }); + + testWidgets('shows Shipped label for shipped status', (tester) async { + await tester.pumpWidget(buildTestWidget(OrderStatus.shipped)); + expect(find.text('Shipped'), findsOneWidget); + }); + + testWidgets('shows Delivered label for delivered status', (tester) async { + await tester.pumpWidget(buildTestWidget(OrderStatus.delivered)); + expect(find.text('Delivered'), findsOneWidget); + }); + + testWidgets('shows Cancelled label for cancelled status', (tester) async { + await tester.pumpWidget(buildTestWidget(OrderStatus.cancelled)); + expect(find.text('Cancelled'), findsOneWidget); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_policy/lib/feature_policy.dart b/kell_creations_apps/packages/feature_policy/lib/feature_policy.dart index 298576d..695847e 100644 --- a/kell_creations_apps/packages/feature_policy/lib/feature_policy.dart +++ b/kell_creations_apps/packages/feature_policy/lib/feature_policy.dart @@ -1,5 +1,7 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +library; + +export 'src/data/fake_policy_repository.dart'; +export 'src/domain/governance_status.dart'; +export 'src/domain/policy_check_result.dart'; +export 'src/domain/policy_repository.dart'; +export 'src/presentation/policy_page.dart'; diff --git a/kell_creations_apps/packages/feature_policy/lib/src/application/get_policy_checks.dart b/kell_creations_apps/packages/feature_policy/lib/src/application/get_policy_checks.dart new file mode 100644 index 0000000..417128a --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/application/get_policy_checks.dart @@ -0,0 +1,11 @@ +import '../domain/policy_check_result.dart'; +import '../domain/policy_repository.dart'; + +/// Use case: retrieve all policy-check results from the repository. +class GetPolicyChecks { + final PolicyRepository repository; + + GetPolicyChecks(this.repository); + + Future> call() => repository.getPolicyChecks(); +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart b/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart new file mode 100644 index 0000000..038d622 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; + +import '../domain/policy_check_result.dart'; +import 'get_policy_checks.dart'; + +/// Controller that manages the policy-checks workspace state, including +/// filtering by category, free-text search, and check selection. +class PolicyController extends ChangeNotifier { + final GetPolicyChecks _getPolicyChecks; + + PolicyController(this._getPolicyChecks); + + bool isLoading = false; + Object? error; + + /// All checks returned by the repository (unfiltered). + List _allChecks = []; + + /// The currently visible checks after applying [activeCategory] and [searchQuery]. + List checks = []; + + /// The currently selected check for detail view. + PolicyCheckResult? selectedCheck; + + /// The active category filter, or `null` for "all". + /// + /// Matches [PolicyCheckResult.category] values such as + /// `'Product Compliance'`, `'Inventory Governance'`, etc. + String? activeCategory; + + /// The current free-text search query applied to title / summary. + String searchQuery = ''; + + /// Loads all policy checks and applies any current filter / search. + Future load() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + _allChecks = await _getPolicyChecks(); + _applyFilters(); + // Auto-select the first check if nothing is selected. + selectedCheck ??= checks.isNotEmpty ? checks.first : null; + } catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } + + /// Sets the category filter and recomputes the visible list. + void setCategory(String? category) { + activeCategory = category; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + + /// Selects a policy check for detail view. + void selectCheck(PolicyCheckResult check) { + selectedCheck = check; + notifyListeners(); + } + + /// Attempts to select a check by ID. Returns `true` if found. + bool selectById(String id) { + final match = _allChecks.where((c) => c.id == id).firstOrNull; + if (match != null) { + selectedCheck = match; + notifyListeners(); + return true; + } + return false; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allChecks; + + // Category filter + if (activeCategory != null && activeCategory!.isNotEmpty) { + result = result.where((c) => c.category == activeCategory).toList(); + } + + // Free-text search on title and summary + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((c) { + return c.title.toLowerCase().contains(q) || c.summary.toLowerCase().contains(q); + }).toList(); + } + + checks = result; + + // Keep selection valid; clear if the selected check is no longer visible. + if (selectedCheck != null && !checks.any((c) => c.id == selectedCheck!.id)) { + selectedCheck = null; + } + } +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/data/fake_policy_repository.dart b/kell_creations_apps/packages/feature_policy/lib/src/data/fake_policy_repository.dart new file mode 100644 index 0000000..0718b7a --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/data/fake_policy_repository.dart @@ -0,0 +1,118 @@ +import '../domain/governance_status.dart'; +import '../domain/policy_check_result.dart'; +import '../domain/policy_repository.dart'; + +/// In-memory fake that returns sample readiness and governance checks +/// derived from Kell Creations products, inventory, and orders. +class FakePolicyRepository implements PolicyRepository { + @override + Future> getPolicyChecks() async { + await Future.delayed(const Duration(milliseconds: 300)); + + final now = DateTime(2026, 4, 4); + + return [ + PolicyCheckResult( + id: 'POL-001', + title: 'Product Safety Labeling', + category: 'Product Compliance', + status: GovernanceStatus.compliant, + summary: 'All active products carry required safety labels.', + detail: + 'Floral Bowl Cozy, Citrus Coaster Set, Ocean Nightlight, Fabric ' + 'Jar Gripper, and Skillet Handle Sleeve have been reviewed and ' + 'carry the appropriate consumer-safety labels per CPSC guidelines. ' + 'Sublimated Slate Coaster is still in draft and will be evaluated ' + 'before listing.', + lastEvaluated: now.subtract(const Duration(days: 2)), + ), + PolicyCheckResult( + id: 'POL-002', + title: 'Inventory Accuracy Audit', + category: 'Inventory Governance', + status: GovernanceStatus.needsReview, + summary: 'Two SKUs show zero on-hand quantity and need recount.', + detail: + 'Ocean Nightlight (NL-OCN-003) and Sublimated Slate Coaster ' + '(SC-SUB-006) report zero quantity on hand. A physical recount ' + 'should be scheduled to confirm whether stock-outs are accurate ' + 'or the result of a data-entry discrepancy.', + lastEvaluated: now.subtract(const Duration(days: 1)), + ), + PolicyCheckResult( + id: 'POL-003', + title: 'Order Fulfillment SLA', + category: 'Order Operations', + status: GovernanceStatus.compliant, + summary: 'All orders shipped within the 3-business-day SLA.', + detail: + 'Review of recent orders (KC-1001 through KC-1006) confirms that ' + 'processing-to-ship times remain within the published 3-business-day ' + 'service-level agreement. No SLA breaches detected.', + lastEvaluated: now, + ), + PolicyCheckResult( + id: 'POL-004', + title: 'Pricing Consistency', + category: 'Product Compliance', + status: GovernanceStatus.nonCompliant, + summary: 'Citrus Coaster Set price differs between store and inventory.', + detail: + 'The Citrus Coaster Set (CS-CIT-002) is listed at \$16.50 in ' + 'inventory but the WordPress storefront shows \$15.99. Prices must ' + 'be reconciled before the next sales cycle to avoid consumer ' + 'protection issues.', + lastEvaluated: now, + ), + PolicyCheckResult( + id: 'POL-005', + title: 'Return & Refund Policy Published', + category: 'Customer Policy', + status: GovernanceStatus.compliant, + summary: 'Return and refund policy is published and up to date.', + detail: + 'The 30-day return and refund policy is published on the Kell ' + 'Creations storefront and was last reviewed on 2026-03-15. No ' + 'changes are required at this time.', + lastEvaluated: now.subtract(const Duration(days: 20)), + ), + PolicyCheckResult( + id: 'POL-006', + title: 'Low-Stock Reorder Trigger', + category: 'Inventory Governance', + status: GovernanceStatus.needsReview, + summary: 'Two products are below the reorder threshold.', + detail: + 'Citrus Coaster Set (7 units) and Skillet Handle Sleeve (5 units) ' + 'are below the 10-unit reorder point. Purchase orders should be ' + 'raised to avoid stock-outs that could delay order fulfillment.', + lastEvaluated: now.subtract(const Duration(days: 1)), + ), + PolicyCheckResult( + id: 'POL-007', + title: 'Sales Tax Configuration', + category: 'Finance & Tax', + status: GovernanceStatus.notApplicable, + summary: 'Sales tax collection is not yet enabled.', + detail: + 'Kell Creations has not yet reached the sales-tax nexus threshold ' + 'in any state. This check will become applicable once annual ' + 'revenue exceeds the relevant economic-nexus limits.', + lastEvaluated: now.subtract(const Duration(days: 30)), + ), + PolicyCheckResult( + id: 'POL-008', + title: 'Cancelled Order Review', + category: 'Order Operations', + status: GovernanceStatus.nonCompliant, + summary: 'A cancelled order lacks a documented reason.', + detail: + 'Order KC-1006 was cancelled but no cancellation reason was ' + 'recorded in the system. Store policy requires a documented reason ' + 'for every cancellation to support dispute resolution and trend ' + 'analysis.', + lastEvaluated: now, + ), + ]; + } +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/domain/governance_status.dart b/kell_creations_apps/packages/feature_policy/lib/src/domain/governance_status.dart new file mode 100644 index 0000000..7f61cf4 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/domain/governance_status.dart @@ -0,0 +1,2 @@ +/// The governance readiness status of a policy check. +enum GovernanceStatus { compliant, nonCompliant, needsReview, notApplicable } diff --git a/kell_creations_apps/packages/feature_policy/lib/src/domain/policy_check_result.dart b/kell_creations_apps/packages/feature_policy/lib/src/domain/policy_check_result.dart new file mode 100644 index 0000000..ac328a0 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/domain/policy_check_result.dart @@ -0,0 +1,22 @@ +import 'governance_status.dart'; + +/// A single policy-check result describing whether a governance rule is met. +class PolicyCheckResult { + final String id; + final String title; + final String category; + final GovernanceStatus status; + final String summary; + final String detail; + final DateTime lastEvaluated; + + const PolicyCheckResult({ + required this.id, + required this.title, + required this.category, + required this.status, + required this.summary, + required this.detail, + required this.lastEvaluated, + }); +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/domain/policy_repository.dart b/kell_creations_apps/packages/feature_policy/lib/src/domain/policy_repository.dart new file mode 100644 index 0000000..8bacaf2 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/domain/policy_repository.dart @@ -0,0 +1,7 @@ +import 'policy_check_result.dart'; + +/// Contract for fetching policy-check results. +abstract class PolicyRepository { + /// Returns all policy-check results. + Future> getPolicyChecks(); +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart b/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart new file mode 100644 index 0000000..af01266 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart @@ -0,0 +1,133 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../application/get_policy_checks.dart'; +import '../application/policy_controller.dart'; +import '../domain/policy_repository.dart'; +import 'widgets/policy_check_card.dart'; +import 'widgets/policy_detail_panel.dart'; + +/// The main Policy page. +/// +/// Displays a list of policy checks on the left and a detail panel on the +/// right. Users can select a check to view its full details. +class PolicyPage extends StatefulWidget { + final PolicyRepository repository; + + /// Optional callback to navigate to a related operational page based on + /// the policy check's category. Provided by the app layer. + final void Function(String category)? onViewRelatedPage; + + /// Optional initial category filter to apply on first load + /// (e.g. `'Product Compliance'`). + final String? initialCategory; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional check ID to pre-select on first load (from a navigation handoff). + final String? initialSelectedId; + + const PolicyPage({ + super.key, + required this.repository, + this.onViewRelatedPage, + this.initialCategory, + this.initialQuery, + this.initialSelectedId, + }); + + @override + State createState() => _PolicyPageState(); +} + +class _PolicyPageState extends State { + late final PolicyController controller; + + @override + void initState() { + super.initState(); + controller = PolicyController(GetPolicyChecks(widget.repository)); + + // Apply any initial category / query before loading. + if (widget.initialCategory != null) { + controller.activeCategory = widget.initialCategory; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by ID if requested. + if (widget.initialSelectedId != null) { + controller.selectById(widget.initialSelectedId!); + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null) { + return const Center(child: Text('Failed to load policy checks.')); + } + + return LayoutBuilder( + builder: (context, constraints) { + // On narrow screens show only the list; on wide screens show + // a master-detail layout. + if (constraints.maxWidth < 800) { + return _buildCheckList(); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 400, child: _buildCheckList()), + const SizedBox(width: KcSpacing.md), + Expanded(child: _buildDetail()), + ], + ); + }, + ); + }, + ); + } + + Widget _buildCheckList() { + return ListView.separated( + itemCount: controller.checks.length, + separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm), + itemBuilder: (context, index) { + final check = controller.checks[index]; + return SizedBox( + height: 160, + child: PolicyCheckCard( + check: check, + isSelected: check.id == controller.selectedCheck?.id, + onTap: () => controller.selectCheck(check), + ), + ); + }, + ); + } + + Widget _buildDetail() { + final selected = controller.selectedCheck; + if (selected == null) { + return const Center(child: Text('Select a policy check to view details')); + } + return PolicyDetailPanel(check: selected, onViewRelatedPage: widget.onViewRelatedPage); + } +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/governance_status_chip.dart b/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/governance_status_chip.dart new file mode 100644 index 0000000..820f579 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/governance_status_chip.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/governance_status.dart'; + +/// A chip that displays the [GovernanceStatus] of a policy check using the +/// design-system [KcStatusChip]. +class GovernanceStatusChip extends StatelessWidget { + final GovernanceStatus status; + + const GovernanceStatusChip({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final (label, bg, fg) = _style(status); + return KcStatusChip(label: label, background: bg, foreground: fg); + } + + static (String, Color, Color) _style(GovernanceStatus status) { + switch (status) { + case GovernanceStatus.compliant: + return ('Compliant', const Color(0xFFE8F5E9), KcColors.success); + case GovernanceStatus.nonCompliant: + return ('Non-compliant', const Color(0xFFFFEBEE), KcColors.danger); + case GovernanceStatus.needsReview: + return ('Needs review', const Color(0xFFFFF8E1), KcColors.warning); + case GovernanceStatus.notApplicable: + return ('N/A', const Color(0xFFECEFF1), KcColors.neutral); + } + } +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/policy_check_card.dart b/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/policy_check_card.dart new file mode 100644 index 0000000..edbbb9f --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/policy_check_card.dart @@ -0,0 +1,76 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/policy_check_result.dart'; +import 'governance_status_chip.dart'; + +/// A card displaying a summary of a [PolicyCheckResult]. +/// +/// Shows the check ID, title, category, status chip, and summary. +/// Highlights when [isSelected] is true. +class PolicyCheckCard extends StatelessWidget { + final PolicyCheckResult check; + final bool isSelected; + final VoidCallback? onTap; + + const PolicyCheckCard({super.key, required this.check, this.isSelected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(KcSpacing.md), + decoration: BoxDecoration( + color: KcColors.surface, + border: Border.all( + color: isSelected ? KcColors.denimBlue : KcColors.border, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + check.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + GovernanceStatusChip(status: check.status), + ], + ), + const SizedBox(height: KcSpacing.xs), + Text( + check.category, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: KcColors.neutral, fontWeight: FontWeight.w600), + ), + const SizedBox(height: KcSpacing.sm), + Text( + check.summary, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + Text( + check.id, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), + ), + ], + ), + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/policy_detail_panel.dart b/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/policy_detail_panel.dart new file mode 100644 index 0000000..0a17f52 --- /dev/null +++ b/kell_creations_apps/packages/feature_policy/lib/src/presentation/widgets/policy_detail_panel.dart @@ -0,0 +1,109 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/policy_check_result.dart'; +import 'governance_status_chip.dart'; + +/// A detail panel that shows the full information for the selected +/// [PolicyCheckResult]. +/// +/// Includes the title, status, category, summary, full detail text, +/// and last-evaluated date. +class PolicyDetailPanel extends StatelessWidget { + final PolicyCheckResult check; + + /// Optional callback to navigate to the related operational page + /// based on the check's category. + final void Function(String category)? onViewRelatedPage; + + const PolicyDetailPanel({super.key, required this.check, this.onViewRelatedPage}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KcCard( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ───────────────────────────────────────────────── + Row( + children: [ + Expanded(child: Text(check.title, style: theme.textTheme.headlineMedium)), + const SizedBox(width: KcSpacing.sm), + GovernanceStatusChip(status: check.status), + ], + ), + const SizedBox(height: KcSpacing.md), + + // ── Metadata ─────────────────────────────────────────────── + _MetadataRow(label: 'Check ID', value: check.id), + _MetadataRow(label: 'Category', value: check.category), + _MetadataRow(label: 'Last Evaluated', value: _formatDate(check.lastEvaluated)), + const SizedBox(height: KcSpacing.md), + + // ── Summary ──────────────────────────────────────────────── + Text('Summary', style: theme.textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + Text(check.summary, style: theme.textTheme.bodyLarge), + const SizedBox(height: KcSpacing.md), + + // ── Detail ───────────────────────────────────────────────── + Text('Detail', style: theme.textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + Text(check.detail, style: theme.textTheme.bodyMedium), + + // ── Related page link ────────────────────────────────────── + if (onViewRelatedPage != null) ...[ + const SizedBox(height: KcSpacing.lg), + GestureDetector( + onTap: () => onViewRelatedPage!(check.category), + child: Text( + 'Go to ${check.category} →', + style: theme.textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + ); + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} + +class _MetadataRow extends StatelessWidget { + final String label; + final String value; + + const _MetadataRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + SizedBox( + width: 130, + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: KcColors.neutral, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_policy/pubspec.yaml b/kell_creations_apps/packages/feature_policy/pubspec.yaml index 0ea69b8..b51b6de 100644 --- a/kell_creations_apps/packages/feature_policy/pubspec.yaml +++ b/kell_creations_apps/packages/feature_policy/pubspec.yaml @@ -1,6 +1,7 @@ name: feature_policy -description: "A new Flutter package project." +description: "Policy governance checks for Kell Creations." version: 0.0.1 +publish_to: "none" homepage: environment: @@ -10,45 +11,12 @@ environment: dependencies: flutter: sdk: flutter + design_system: + path: ../design_system dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package diff --git a/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart b/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart index f99d883..4952dfa 100644 --- a/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart +++ b/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart @@ -1,12 +1,177 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:feature_policy/feature_policy.dart'; +import 'package:feature_policy/src/application/get_policy_checks.dart'; +import 'package:feature_policy/src/application/policy_controller.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + group('GovernanceStatus', () { + test('has four values', () { + expect(GovernanceStatus.values.length, 4); + }); + + test('contains expected statuses', () { + expect(GovernanceStatus.values, contains(GovernanceStatus.compliant)); + expect(GovernanceStatus.values, contains(GovernanceStatus.nonCompliant)); + expect(GovernanceStatus.values, contains(GovernanceStatus.needsReview)); + expect(GovernanceStatus.values, contains(GovernanceStatus.notApplicable)); + }); + }); + + group('FakePolicyRepository', () { + late FakePolicyRepository repository; + + setUp(() { + repository = FakePolicyRepository(); + }); + + test('getPolicyChecks returns eight sample checks', () async { + final checks = await repository.getPolicyChecks(); + expect(checks.length, 8); + }); + + test('getPolicyChecks returns checks with expected IDs', () async { + final checks = await repository.getPolicyChecks(); + final ids = checks.map((c) => c.id).toList(); + expect(ids, contains('POL-001')); + expect(ids, contains('POL-002')); + expect(ids, contains('POL-003')); + expect(ids, contains('POL-004')); + expect(ids, contains('POL-005')); + expect(ids, contains('POL-006')); + expect(ids, contains('POL-007')); + expect(ids, contains('POL-008')); + }); + + test('getPolicyChecks returns checks with various statuses', () async { + final checks = await repository.getPolicyChecks(); + final statuses = checks.map((c) => c.status).toSet(); + expect(statuses, contains(GovernanceStatus.compliant)); + expect(statuses, contains(GovernanceStatus.nonCompliant)); + expect(statuses, contains(GovernanceStatus.needsReview)); + expect(statuses, contains(GovernanceStatus.notApplicable)); + }); + + test('getPolicyChecks returns checks with various categories', () async { + final checks = await repository.getPolicyChecks(); + final categories = checks.map((c) => c.category).toSet(); + expect(categories, contains('Product Compliance')); + expect(categories, contains('Inventory Governance')); + expect(categories, contains('Order Operations')); + expect(categories, contains('Customer Policy')); + expect(categories, contains('Finance & Tax')); + }); + }); + + group('PolicyController', () { + late FakePolicyRepository repository; + late PolicyController controller; + + setUp(() { + repository = FakePolicyRepository(); + controller = PolicyController(GetPolicyChecks(repository)); + }); + + tearDown(() { + controller.dispose(); + }); + + test('starts with empty state', () { + expect(controller.isLoading, false); + expect(controller.checks, isEmpty); + expect(controller.selectedCheck, isNull); + expect(controller.activeCategory, isNull); + expect(controller.searchQuery, ''); + expect(controller.error, isNull); + }); + + test('load populates checks and auto-selects first', () async { + await controller.load(); + + expect(controller.isLoading, false); + expect(controller.checks.length, 8); + expect(controller.selectedCheck, isNotNull); + expect(controller.selectedCheck!.id, 'POL-001'); + expect(controller.error, isNull); + }); + + test('selectCheck updates selectedCheck', () async { + await controller.load(); + + final third = controller.checks[2]; + controller.selectCheck(third); + + expect(controller.selectedCheck!.id, third.id); + }); + + test('setCategory filters by category', () async { + await controller.load(); + + controller.setCategory('Product Compliance'); + expect(controller.checks.length, 2); + expect(controller.checks.every((c) => c.category == 'Product Compliance'), true); + }); + + test('setCategory with null shows all checks', () async { + await controller.load(); + + controller.setCategory('Order Operations'); + expect(controller.checks.length, 2); + + controller.setCategory(null); + expect(controller.checks.length, 8); + }); + + test('setSearchQuery filters by title', () async { + await controller.load(); + + controller.setSearchQuery('pricing'); + expect(controller.checks.length, 1); + expect(controller.checks.first.title, 'Pricing Consistency'); + }); + + test('setSearchQuery filters by summary', () async { + await controller.load(); + + controller.setSearchQuery('reorder threshold'); + expect(controller.checks.length, 1); + expect(controller.checks.first.id, 'POL-006'); + }); + + test('category and search combine', () async { + await controller.load(); + + controller.setCategory('Inventory Governance'); + controller.setSearchQuery('accuracy'); + expect(controller.checks.length, 1); + expect(controller.checks.first.id, 'POL-002'); + }); + + test('selectById selects matching check', () async { + await controller.load(); + + final found = controller.selectById('POL-005'); + expect(found, true); + expect(controller.selectedCheck!.id, 'POL-005'); + }); + + test('selectById returns false for unknown ID', () async { + await controller.load(); + + final found = controller.selectById('UNKNOWN'); + expect(found, false); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select a Product Compliance check. + controller.selectById('POL-001'); + expect(controller.selectedCheck, isNotNull); + + // Filter to Order Operations — the selected check should be cleared. + controller.setCategory('Order Operations'); + expect(controller.selectedCheck, isNull); + }); }); } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 298576d..2595219 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -1,5 +1,10 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +library; + +export 'src/data/fake_product_publishing_repository.dart'; +export 'src/data/woo_commerce_api_client.dart'; +export 'src/data/wordpress_product_mapper.dart'; +export 'src/data/wordpress_product_publishing_repository.dart'; +export 'src/domain/product_draft.dart'; +export 'src/domain/product_publishing_repository.dart'; +export 'src/domain/publish_status.dart'; +export 'src/presentation/product_publishing_page.dart'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.dart new file mode 100644 index 0000000..895281e --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.dart @@ -0,0 +1,11 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; + +/// Use case: retrieve all product drafts from the repository. +class GetProductDrafts { + final ProductPublishingRepository repository; + + GetProductDrafts(this.repository); + + Future> call() => repository.getProductDrafts(); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart new file mode 100644 index 0000000..69ff0a5 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart @@ -0,0 +1,139 @@ +import 'package:flutter/foundation.dart'; + +import '../domain/product_draft.dart'; +import '../domain/publish_status.dart'; +import 'get_product_drafts.dart'; +import 'publish_product.dart'; + +/// Controller that manages the product publishing workspace state, including +/// filtering by publish status, free-text search, and draft selection. +class ProductPublishingController extends ChangeNotifier { + final GetProductDrafts _getProductDrafts; + final PublishProduct _publishProduct; + + ProductPublishingController(this._getProductDrafts, this._publishProduct); + + bool isLoading = false; + Object? error; + + /// All drafts returned by the repository (unfiltered). + List _allDrafts = []; + + /// The currently visible drafts after applying [activeFilter] and [searchQuery]. + List drafts = []; + + /// The currently selected draft for preview. + ProductDraft? selectedDraft; + + /// The active status filter label, or `null` for "all". + /// + /// Recognised values: `'draft'`, `'pendingReview'`, `'published'`, `'unpublished'`. + String? activeFilter; + + /// The current free-text search query applied to name / SKU. + String searchQuery = ''; + + /// Loads all product drafts and applies any current filter / search. + Future load() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + _allDrafts = await _getProductDrafts(); + _applyFilters(); + // Auto-select the first draft if nothing is selected. + selectedDraft ??= drafts.isNotEmpty ? drafts.first : null; + } catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } + + /// Sets the status filter and recomputes the visible list. + void setFilter(String? filter) { + activeFilter = filter; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + + /// Selects a draft for preview. + void selectDraft(ProductDraft draft) { + selectedDraft = draft; + notifyListeners(); + } + + /// Attempts to select a draft by SKU. Returns `true` if found. + bool selectBySku(String sku) { + final match = _allDrafts.where((d) => d.sku == sku).firstOrNull; + if (match != null) { + selectedDraft = match; + notifyListeners(); + return true; + } + return false; + } + + /// Publishes the draft with the given [id] and reloads the list. + Future publish(String id) async { + try { + await _publishProduct(id); + await load(); + } catch (e) { + error = e; + notifyListeners(); + } + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allDrafts; + + // Status filter + final status = _parseStatus(activeFilter); + if (status != null) { + result = result.where((d) => d.status == status).toList(); + } + + // Free-text search on name and SKU + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((d) { + return d.name.toLowerCase().contains(q) || d.sku.toLowerCase().contains(q); + }).toList(); + } + + drafts = result; + + // Keep selection valid; clear if the selected draft is no longer visible. + if (selectedDraft != null && !drafts.any((d) => d.id == selectedDraft!.id)) { + selectedDraft = null; + } + } + + static PublishStatus? _parseStatus(String? filter) { + if (filter == null) return null; + switch (filter) { + case 'draft': + return PublishStatus.draft; + case 'pendingReview': + return PublishStatus.pendingReview; + case 'published': + return PublishStatus.published; + case 'unpublished': + return PublishStatus.unpublished; + default: + return null; + } + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart new file mode 100644 index 0000000..6e2672f --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart @@ -0,0 +1,11 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; + +/// Use case: publish a single product draft by its [id]. +class PublishProduct { + final ProductPublishingRepository repository; + + PublishProduct(this.repository); + + Future call(String id) => repository.publishDraft(id); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart new file mode 100644 index 0000000..7f27e2f --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart @@ -0,0 +1,120 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; +import '../domain/publish_status.dart'; + +/// Stubbed implementation of [ProductPublishingRepository] with sample +/// Kell Creations products. No real WordPress/WooCommerce API calls are made. +class FakeProductPublishingRepository implements ProductPublishingRepository { + final List _drafts = [ + ProductDraft( + id: '1', + name: 'Floral Bowl Cozy', + description: + 'A beautifully crafted fabric bowl cozy with a vibrant floral pattern. ' + 'Microwave-safe and perfect for keeping dishes warm at the table.', + price: 12.99, + sku: 'BC-FLR-001', + category: 'Bowl Cozies', + imageUrl: '', + status: PublishStatus.published, + lastModified: DateTime(2026, 3, 28), + ), + ProductDraft( + id: '2', + name: 'Citrus Coaster Set', + description: + 'Set of four sublimated coasters featuring bright citrus designs. ' + 'Heat-resistant cork backing protects surfaces.', + price: 16.50, + sku: 'CS-CIT-002', + category: 'Coasters', + imageUrl: '', + status: PublishStatus.published, + lastModified: DateTime(2026, 3, 25), + ), + ProductDraft( + id: '3', + name: 'Ocean Nightlight', + description: + 'Sublimated ceramic nightlight with a calming ocean wave design. ' + 'Includes LED bulb and plugs into any standard outlet.', + price: 19.99, + sku: 'NL-OCN-003', + category: 'Nightlights', + imageUrl: '', + status: PublishStatus.pendingReview, + lastModified: DateTime(2026, 4, 1), + ), + ProductDraft( + id: '4', + name: 'Fabric Jar Gripper', + description: + 'Non-slip fabric jar gripper with a fun patterned design. ' + 'Opens even the tightest lids with ease.', + 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: + 'Quilted fabric sleeve that slips over hot skillet handles. ' + 'Available in multiple patterns to match your kitchen décor.', + price: 10.99, + sku: 'SH-SUN-005', + category: 'Kitchen Accessories', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 4, 3), + ), + ProductDraft( + id: '6', + name: 'Sublimated Slate Coaster', + description: + 'Natural slate coaster with a full-color sublimated image. ' + 'Felt feet protect furniture. Makes a great personalized gift.', + price: 14.99, + sku: 'SC-SUB-006', + category: 'Coasters', + imageUrl: '', + status: PublishStatus.unpublished, + lastModified: DateTime(2026, 3, 20), + ), + ]; + + @override + Future> getProductDrafts() async { + // Simulate network latency. + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_drafts); + } + + @override + Future publishDraft(String id) async { + await Future.delayed(const Duration(milliseconds: 500)); + + final index = _drafts.indexWhere((d) => d.id == id); + if (index == -1) { + throw StateError('Draft with id $id not found'); + } + + final original = _drafts[index]; + final updated = ProductDraft( + id: original.id, + name: original.name, + description: original.description, + price: original.price, + sku: original.sku, + category: original.category, + imageUrl: original.imageUrl, + status: PublishStatus.published, + lastModified: DateTime.now(), + ); + _drafts[index] = updated; + return updated; + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart new file mode 100644 index 0000000..caaa55a --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Lightweight HTTP client for the WooCommerce REST API v3. +/// +/// Handles authentication (Basic Auth over HTTPS) and pagination. +/// Only read-only product listing is implemented for now. +class WooCommerceApiClient { + /// Base URL of the WordPress site (e.g. `https://store.kellcreations.com`). + final String siteUrl; + + /// WooCommerce REST API consumer key. + final String consumerKey; + + /// WooCommerce REST API consumer secret. + final String consumerSecret; + + /// Optional [http.Client] for testing / injection. + final http.Client _httpClient; + + WooCommerceApiClient({ + required this.siteUrl, + required this.consumerKey, + required this.consumerSecret, + http.Client? httpClient, + }) : _httpClient = httpClient ?? http.Client(); + + /// The base endpoint for WooCommerce REST API v3. + String get _baseEndpoint => '$siteUrl/wp-json/wc/v3'; + + /// Fetches a paginated list of products from WooCommerce. + /// + /// [page] is 1-based. [perPage] defaults to 100 (WooCommerce max). + /// Returns the raw JSON list of product maps. + Future>> getProducts({int page = 1, int perPage = 100}) async { + final uri = Uri.parse( + '$_baseEndpoint/products', + ).replace(queryParameters: {'page': page.toString(), 'per_page': perPage.toString()}); + + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to fetch products: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON array', + body: response.body, + ); + } + + return decoded.cast>(); + } + + /// Fetches all products by paginating through the WooCommerce API. + /// + /// Keeps requesting pages until a page returns fewer items than [perPage]. + Future>> getAllProducts({int perPage = 100}) async { + final allProducts = >[]; + var page = 1; + + while (true) { + final batch = await getProducts(page: page, perPage: perPage); + allProducts.addAll(batch); + + if (batch.length < perPage) break; + page++; + } + + return allProducts; + } + + /// Basic Auth headers for WooCommerce REST API. + Map get _authHeaders { + final credentials = base64Encode(utf8.encode('$consumerKey:$consumerSecret')); + return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'}; + } + + /// Releases the underlying HTTP client resources. + void dispose() { + _httpClient.close(); + } +} + +/// Exception thrown when the WooCommerce API returns an error. +class WooCommerceApiException implements Exception { + final int statusCode; + final String message; + final String body; + + const WooCommerceApiException({ + required this.statusCode, + required this.message, + required this.body, + }); + + @override + String toString() => 'WooCommerceApiException($statusCode): $message'; +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart new file mode 100644 index 0000000..372a386 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart @@ -0,0 +1,114 @@ +import '../domain/product_draft.dart'; +import '../domain/publish_status.dart'; + +/// Maps raw WooCommerce REST API v3 product JSON into [ProductDraft] domain +/// objects. +/// +/// Only the fields needed for read-only product retrieval are mapped. +/// See https://woocommerce.github.io/woocommerce-rest-api-docs/#product-properties +class WordPressProductMapper { + const WordPressProductMapper(); + + /// Converts a single WooCommerce product JSON map to a [ProductDraft]. + ProductDraft fromJson(Map json) { + return ProductDraft( + id: json['id']?.toString() ?? '', + name: (json['name'] as String?) ?? '', + description: _stripHtml((json['description'] as String?) ?? ''), + price: _parsePrice(json['price']), + sku: (json['sku'] as String?) ?? '', + category: _firstCategoryName(json['categories']), + imageUrl: _firstImageUrl(json['images']), + status: _mapStatus(json['status'] as String?), + lastModified: _parseDate(json['date_modified'] ?? json['date_created']), + ); + } + + /// Converts a list of WooCommerce product JSON maps to [ProductDraft]s. + List fromJsonList(List> jsonList) { + return jsonList.map(fromJson).toList(); + } + + // ── Private helpers ────────────────────────────────────────────────── + + /// Maps the WooCommerce `status` string to our [PublishStatus] enum. + /// + /// WooCommerce statuses: `publish`, `draft`, `pending`, `private`, `trash`. + static PublishStatus _mapStatus(String? status) { + switch (status) { + case 'publish': + return PublishStatus.published; + case 'draft': + return PublishStatus.draft; + case 'pending': + return PublishStatus.pendingReview; + case 'private': + case 'trash': + return PublishStatus.unpublished; + default: + return PublishStatus.draft; + } + } + + /// Parses a price value that may arrive as a String or num. + static double _parsePrice(dynamic value) { + if (value == null) return 0.0; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? 0.0; + return 0.0; + } + + /// Extracts the name of the first category, or `'Uncategorized'`. + /// + /// WooCommerce returns categories as: + /// ```json + /// [{"id": 1, "name": "Bowl Cozies", "slug": "bowl-cozies"}] + /// ``` + static String _firstCategoryName(dynamic categories) { + if (categories is List && categories.isNotEmpty) { + final first = categories.first; + if (first is Map) { + return (first['name'] as String?) ?? 'Uncategorized'; + } + } + return 'Uncategorized'; + } + + /// Extracts the `src` URL of the first product image, or empty string. + /// + /// WooCommerce returns images as: + /// ```json + /// [{"id": 1, "src": "https://...", "name": "image.jpg", ...}] + /// ``` + static String _firstImageUrl(dynamic images) { + if (images is List && images.isNotEmpty) { + final first = images.first; + if (first is Map) { + return (first['src'] as String?) ?? ''; + } + } + return ''; + } + + /// Parses an ISO 8601 date string, falling back to [DateTime.now]. + static DateTime _parseDate(dynamic value) { + if (value is String && value.isNotEmpty) { + return DateTime.tryParse(value) ?? DateTime.now(); + } + return DateTime.now(); + } + + /// Strips basic HTML tags from a string. + /// + /// WooCommerce descriptions often contain `

`, `
`, etc. + static String _stripHtml(String html) { + return html + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .trim(); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart new file mode 100644 index 0000000..094894a --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart @@ -0,0 +1,37 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; +import 'woo_commerce_api_client.dart'; +import 'wordpress_product_mapper.dart'; + +/// Real [ProductPublishingRepository] backed by the WooCommerce REST API. +/// +/// Currently implements **read-only** product retrieval. The [publishDraft] +/// method throws [UnimplementedError] — full publishing support will be +/// added in a future iteration. +class WordPressProductPublishingRepository implements ProductPublishingRepository { + final WooCommerceApiClient _apiClient; + final WordPressProductMapper _mapper; + + WordPressProductPublishingRepository({ + required WooCommerceApiClient apiClient, + WordPressProductMapper mapper = const WordPressProductMapper(), + }) : _apiClient = apiClient, + _mapper = mapper; + + @override + Future> getProductDrafts() async { + final jsonProducts = await _apiClient.getAllProducts(); + return _mapper.fromJsonList(jsonProducts); + } + + @override + Future publishDraft(String id) { + // Publishing is not yet implemented for the real backend. + // This will be added in a future iteration alongside media upload + // and order sync. + throw UnimplementedError( + 'WordPressProductPublishingRepository.publishDraft is not yet implemented. ' + 'Use FakeProductPublishingRepository for development.', + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart new file mode 100644 index 0000000..15917c3 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart @@ -0,0 +1,26 @@ +import 'publish_status.dart'; + +/// A product draft that may be published to the WordPress/WooCommerce store. +class ProductDraft { + final String id; + final String name; + final String description; + final double price; + final String sku; + final String category; + final String imageUrl; + final PublishStatus status; + final DateTime lastModified; + + const ProductDraft({ + required this.id, + required this.name, + required this.description, + required this.price, + required this.sku, + required this.category, + required this.imageUrl, + required this.status, + required this.lastModified, + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart new file mode 100644 index 0000000..0106749 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart @@ -0,0 +1,10 @@ +import 'product_draft.dart'; + +/// Contract for fetching and managing product drafts. +abstract class ProductPublishingRepository { + /// Returns all product drafts. + Future> getProductDrafts(); + + /// Publishes a draft by [id]. Returns the updated draft. + Future publishDraft(String id); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart new file mode 100644 index 0000000..cdc1042 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart @@ -0,0 +1,14 @@ +/// The publishing status of a product draft on the WordPress/WooCommerce store. +enum PublishStatus { + /// Product is still being drafted; not yet sent to the store. + draft, + + /// Product has been submitted and is awaiting review. + pendingReview, + + /// Product is live on the store. + published, + + /// Product was previously published but has been taken down. + unpublished, +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart new file mode 100644 index 0000000..f2694f6 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart @@ -0,0 +1,138 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../application/get_product_drafts.dart'; +import '../application/product_publishing_controller.dart'; +import '../application/publish_product.dart'; +import '../domain/product_publishing_repository.dart'; +import 'widgets/product_draft_card.dart'; +import 'widgets/product_preview_panel.dart'; + +/// The main Product Publishing Workspace page. +/// +/// 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. +class ProductPublishingPage extends StatefulWidget { + final ProductPublishingRepository repository; + + /// Optional callback to navigate to the Policy page. + /// Provided by the app layer to enable cross-feature handoffs. + final VoidCallback? onViewPolicy; + + /// Optional initial status filter to apply on first load (e.g. `'draft'`). + final String? initialFilter; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional SKU to pre-select on first load (from a navigation handoff). + final String? initialSelectedSku; + + const ProductPublishingPage({ + super.key, + required this.repository, + this.onViewPolicy, + this.initialFilter, + this.initialQuery, + this.initialSelectedSku, + }); + + @override + State createState() => _ProductPublishingPageState(); +} + +class _ProductPublishingPageState extends State { + late final ProductPublishingController controller; + + @override + void initState() { + super.initState(); + final repo = widget.repository; + controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo)); + + // Apply any initial filter / query before loading. + if (widget.initialFilter != null) { + controller.activeFilter = widget.initialFilter; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by SKU if requested. + if (widget.initialSelectedSku != null) { + controller.selectBySku(widget.initialSelectedSku!); + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null) { + return const Center(child: Text('Failed to load product drafts.')); + } + + return LayoutBuilder( + builder: (context, constraints) { + // On narrow screens show only the list; on wide screens show + // a master-detail layout. + if (constraints.maxWidth < 800) { + return _buildDraftList(); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 380, child: _buildDraftList()), + const SizedBox(width: KcSpacing.md), + Expanded(child: _buildPreview()), + ], + ); + }, + ); + }, + ); + } + + Widget _buildDraftList() { + return ListView.separated( + itemCount: controller.drafts.length, + separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm), + itemBuilder: (context, index) { + final draft = controller.drafts[index]; + return SizedBox( + height: 160, + child: ProductDraftCard( + draft: draft, + isSelected: draft.id == controller.selectedDraft?.id, + onTap: () => controller.selectDraft(draft), + ), + ); + }, + ); + } + + Widget _buildPreview() { + final selected = controller.selectedDraft; + if (selected == null) { + return const Center(child: Text('Select a product draft to preview')); + } + return ProductPreviewPanel( + draft: selected, + onPublish: () => controller.publish(selected.id), + onViewPolicy: widget.onViewPolicy, + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart new file mode 100644 index 0000000..d764d64 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart @@ -0,0 +1,70 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/product_draft.dart'; +import 'publish_status_chip.dart'; + +/// A card displaying a summary of a [ProductDraft]. +/// +/// Shows the product name, SKU, price, category, and publish status. +/// Highlights when [isSelected] is true. +class ProductDraftCard extends StatelessWidget { + final ProductDraft draft; + final bool isSelected; + final VoidCallback? onTap; + + const ProductDraftCard({super.key, required this.draft, this.isSelected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(KcSpacing.md), + decoration: BoxDecoration( + color: KcColors.surface, + border: Border.all( + color: isSelected ? KcColors.denimBlue : KcColors.border, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + draft.name, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: KcSpacing.xs), + Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: KcSpacing.sm), + Row( + children: [ + Text( + '\$${draft.price.toStringAsFixed(2)}', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(width: KcSpacing.sm), + Text( + draft.category, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral), + ), + ], + ), + const Spacer(), + PublishStatusChip(status: draft.status), + ], + ), + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart new file mode 100644 index 0000000..40c2d30 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart @@ -0,0 +1,131 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/product_draft.dart'; +import '../../domain/publish_status.dart'; +import 'publish_status_chip.dart'; + +/// A detail panel that shows a full preview of the selected [ProductDraft]. +/// +/// Includes product image placeholder, description, metadata, and a +/// publish action button when the draft is not yet published. +class ProductPreviewPanel extends StatelessWidget { + final ProductDraft draft; + final VoidCallback? onPublish; + + /// Optional callback to navigate to the Policy page. + final VoidCallback? onViewPolicy; + + const ProductPreviewPanel({super.key, required this.draft, this.onPublish, this.onViewPolicy}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KcCard( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Image placeholder ────────────────────────────────────── + Container( + height: 180, + width: double.infinity, + decoration: BoxDecoration( + color: KcColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: KcColors.border), + ), + child: const Center( + child: Icon(Icons.image_outlined, size: 48, color: KcColors.neutral), + ), + ), + const SizedBox(height: KcSpacing.md), + + // ── Title & status ───────────────────────────────────────── + Row( + children: [ + Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)), + const SizedBox(width: KcSpacing.sm), + PublishStatusChip(status: draft.status), + ], + ), + const SizedBox(height: KcSpacing.sm), + + // ── Metadata ─────────────────────────────────────────────── + _MetadataRow(label: 'SKU', value: draft.sku), + _MetadataRow(label: 'Price', value: '\$${draft.price.toStringAsFixed(2)}'), + _MetadataRow(label: 'Category', value: draft.category), + _MetadataRow( + label: 'Last Modified', + value: + '${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}', + ), + const SizedBox(height: KcSpacing.md), + + // ── Description ──────────────────────────────────────────── + Text('Description', style: theme.textTheme.titleLarge), + const SizedBox(height: KcSpacing.sm), + Text(draft.description, style: theme.textTheme.bodyLarge), + const SizedBox(height: KcSpacing.xl), + + // ── Policy link ──────────────────────────────────────────── + if (onViewPolicy != null) ...[ + GestureDetector( + onTap: onViewPolicy, + child: Text( + 'View Compliance Policy →', + style: theme.textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: KcSpacing.md), + ], + + // ── Publish button ───────────────────────────────────────── + if (draft.status != PublishStatus.published) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onPublish, + icon: const Icon(Icons.publish), + label: const Text('Publish to Store'), + ), + ), + ], + ), + ), + ); + } +} + +class _MetadataRow extends StatelessWidget { + final String label; + final String value; + + const _MetadataRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: KcColors.neutral, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart new file mode 100644 index 0000000..af6890c --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/publish_status.dart'; + +/// A chip that displays the [PublishStatus] of a product draft using the +/// design-system [KcStatusChip]. +class PublishStatusChip extends StatelessWidget { + final PublishStatus status; + + const PublishStatusChip({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final (label, bg, fg) = _style(status); + return KcStatusChip(label: label, background: bg, foreground: fg); + } + + static (String, Color, Color) _style(PublishStatus status) { + switch (status) { + case PublishStatus.draft: + return ('Draft', const Color(0xFFECEFF1), KcColors.neutral); + case PublishStatus.pendingReview: + return ('Pending Review', const Color(0xFFFFF8E1), KcColors.warning); + case PublishStatus.published: + return ('Published', const Color(0xFFE8F5E9), KcColors.success); + case PublishStatus.unpublished: + return ('Unpublished', const Color(0xFFFFEBEE), KcColors.danger); + } + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml index 3d04c67..526e61a 100644 --- a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml +++ b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml @@ -1,6 +1,7 @@ name: feature_wordpress -description: "A new Flutter package project." +description: "Product publishing workspace for WordPress/WooCommerce integration." version: 0.0.1 +publish_to: "none" homepage: environment: @@ -10,45 +11,13 @@ environment: dependencies: flutter: sdk: flutter + design_system: + path: ../design_system + http: ^1.4.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package diff --git a/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart new file mode 100644 index 0000000..70a8ff3 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; + +void main() { + late FakeProductPublishingRepository repository; + + setUp(() { + repository = FakeProductPublishingRepository(); + }); + + group('FakeProductPublishingRepository', () { + test('getProductDrafts returns six sample products', () async { + final drafts = await repository.getProductDrafts(); + expect(drafts.length, 6); + }); + + test('getProductDrafts returns products with expected names', () async { + final drafts = await repository.getProductDrafts(); + final names = drafts.map((d) => d.name).toList(); + expect(names, contains('Floral Bowl Cozy')); + expect(names, contains('Citrus Coaster Set')); + expect(names, contains('Ocean Nightlight')); + expect(names, contains('Fabric Jar Gripper')); + expect(names, contains('Skillet Handle Sleeve')); + expect(names, contains('Sublimated Slate Coaster')); + }); + + test('getProductDrafts returns products with various statuses', () async { + final drafts = await repository.getProductDrafts(); + final statuses = drafts.map((d) => d.status).toSet(); + expect(statuses, contains(PublishStatus.published)); + expect(statuses, contains(PublishStatus.pendingReview)); + expect(statuses, contains(PublishStatus.draft)); + expect(statuses, contains(PublishStatus.unpublished)); + }); + + test('publishDraft changes status to published', () async { + // Product 4 starts as draft. + final updated = await repository.publishDraft('4'); + expect(updated.status, PublishStatus.published); + expect(updated.name, 'Fabric Jar Gripper'); + }); + + test('publishDraft throws for unknown id', () async { + expect(() => repository.publishDraft('unknown'), throwsA(isA())); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart b/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart index 4181d7c..c27f267 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart @@ -3,10 +3,42 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:feature_wordpress/feature_wordpress.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + group('PublishStatus', () { + test('has four values', () { + expect(PublishStatus.values.length, 4); + }); + + test('contains expected statuses', () { + expect(PublishStatus.values, contains(PublishStatus.draft)); + expect(PublishStatus.values, contains(PublishStatus.pendingReview)); + expect(PublishStatus.values, contains(PublishStatus.published)); + expect(PublishStatus.values, contains(PublishStatus.unpublished)); + }); + }); + + group('ProductDraft', () { + test('can be constructed with required fields', () { + final draft = ProductDraft( + id: '1', + name: 'Test Product', + description: 'A test product', + price: 9.99, + sku: 'TP-001', + category: 'Test', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 4, 1), + ); + + expect(draft.id, '1'); + expect(draft.name, 'Test Product'); + expect(draft.description, 'A test product'); + expect(draft.price, 9.99); + expect(draft.sku, 'TP-001'); + expect(draft.category, 'Test'); + expect(draft.imageUrl, ''); + expect(draft.status, PublishStatus.draft); + expect(draft.lastModified, DateTime(2026, 4, 1)); + }); }); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart new file mode 100644 index 0000000..f1708bf --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.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/publish_product.dart'; + +void main() { + late FakeProductPublishingRepository repository; + late ProductPublishingController controller; + + setUp(() { + repository = FakeProductPublishingRepository(); + controller = ProductPublishingController( + GetProductDrafts(repository), + PublishProduct(repository), + ); + }); + + tearDown(() { + controller.dispose(); + }); + + group('ProductPublishingController', () { + test('starts with empty state', () { + expect(controller.isLoading, false); + expect(controller.drafts, isEmpty); + expect(controller.selectedDraft, isNull); + expect(controller.activeFilter, isNull); + expect(controller.searchQuery, ''); + expect(controller.error, isNull); + }); + + test('load populates drafts and auto-selects first', () async { + await controller.load(); + + expect(controller.isLoading, false); + expect(controller.drafts.length, 6); + expect(controller.selectedDraft, isNotNull); + expect(controller.selectedDraft!.id, '1'); + expect(controller.error, isNull); + }); + + test('selectDraft updates selectedDraft', () async { + await controller.load(); + + final third = controller.drafts[2]; + controller.selectDraft(third); + + expect(controller.selectedDraft!.id, third.id); + }); + + test('publish changes draft status and reloads', () async { + await controller.load(); + + // Draft id 4 starts as PublishStatus.draft. + await controller.publish('4'); + + final updated = controller.drafts.firstWhere((d) => d.id == '4'); + expect(updated.status, PublishStatus.published); + }); + + test('setFilter filters by publish status', () async { + await controller.load(); + + controller.setFilter('draft'); + expect(controller.drafts.length, 2); + expect(controller.drafts.every((d) => d.status == PublishStatus.draft), true); + }); + + test('setFilter with null shows all drafts', () async { + await controller.load(); + + controller.setFilter('published'); + expect(controller.drafts.length, 2); + + controller.setFilter(null); + expect(controller.drafts.length, 6); + }); + + test('setSearchQuery filters by name', () async { + await controller.load(); + + controller.setSearchQuery('nightlight'); + expect(controller.drafts.length, 1); + expect(controller.drafts.first.name, 'Ocean Nightlight'); + }); + + test('setSearchQuery filters by SKU', () async { + await controller.load(); + + controller.setSearchQuery('JG-BLU'); + expect(controller.drafts.length, 1); + expect(controller.drafts.first.sku, 'JG-BLU-004'); + }); + + test('filter and search combine', () async { + await controller.load(); + + controller.setFilter('published'); + controller.setSearchQuery('floral'); + expect(controller.drafts.length, 1); + expect(controller.drafts.first.name, 'Floral Bowl Cozy'); + }); + + test('selectBySku selects matching draft', () async { + await controller.load(); + + final found = controller.selectBySku('CS-CIT-002'); + expect(found, true); + expect(controller.selectedDraft!.sku, 'CS-CIT-002'); + }); + + test('selectBySku returns false for unknown SKU', () async { + await controller.load(); + + final found = controller.selectBySku('UNKNOWN'); + expect(found, false); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select a published draft. + controller.selectBySku('BC-FLR-001'); + expect(controller.selectedDraft, isNotNull); + + // Filter to draft status — the selected item should be cleared. + controller.setFilter('draft'); + expect(controller.selectedDraft, isNull); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart new file mode 100644 index 0000000..7c7ec25 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart @@ -0,0 +1,67 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:feature_wordpress/src/presentation/widgets/product_draft_card.dart'; + +void main() { + final sampleDraft = ProductDraft( + id: '1', + name: 'Test Bowl Cozy', + description: 'A test product', + price: 12.99, + sku: 'BC-TST-001', + category: 'Bowl Cozies', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 4, 1), + ); + + Widget buildTestWidget({bool isSelected = false, VoidCallback? onTap}) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold( + body: SizedBox( + height: 200, + width: 400, + child: ProductDraftCard(draft: sampleDraft, isSelected: isSelected, onTap: onTap), + ), + ), + ); + } + + group('ProductDraftCard', () { + testWidgets('displays product name', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('Test Bowl Cozy'), findsOneWidget); + }); + + testWidgets('displays SKU', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('SKU: BC-TST-001'), findsOneWidget); + }); + + testWidgets('displays price', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('\$12.99'), findsOneWidget); + }); + + testWidgets('displays category', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('Bowl Cozies'), findsOneWidget); + }); + + testWidgets('displays status chip', (tester) async { + await tester.pumpWidget(buildTestWidget()); + expect(find.text('Draft'), findsOneWidget); + }); + + testWidgets('calls onTap when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget(buildTestWidget(onTap: () => tapped = true)); + await tester.tap(find.text('Test Bowl Cozy')); + expect(tapped, true); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart new file mode 100644 index 0000000..c2ac569 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart @@ -0,0 +1,87 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:feature_wordpress/src/presentation/widgets/product_preview_panel.dart'; + +void main() { + final sampleDraft = ProductDraft( + id: '1', + name: 'Test Bowl Cozy', + description: 'A beautifully crafted test product.', + price: 12.99, + sku: 'BC-TST-001', + category: 'Bowl Cozies', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 4, 1), + ); + + final publishedDraft = ProductDraft( + id: '2', + name: 'Published Product', + description: 'Already published.', + price: 19.99, + sku: 'PP-001', + category: 'Coasters', + imageUrl: '', + status: PublishStatus.published, + lastModified: DateTime(2026, 3, 28), + ); + + Widget buildTestWidget(ProductDraft draft, {VoidCallback? onPublish}) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold( + body: SingleChildScrollView( + child: ProductPreviewPanel(draft: draft, onPublish: onPublish), + ), + ), + ); + } + + group('ProductPreviewPanel', () { + testWidgets('displays product name', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('Test Bowl Cozy'), findsOneWidget); + }); + + testWidgets('displays description', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('A beautifully crafted test product.'), findsOneWidget); + }); + + testWidgets('displays SKU metadata', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('BC-TST-001'), findsOneWidget); + }); + + testWidgets('displays price metadata', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('\$12.99'), findsOneWidget); + }); + + testWidgets('displays category metadata', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('Bowl Cozies'), findsOneWidget); + }); + + testWidgets('shows publish button for non-published drafts', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('Publish to Store'), findsOneWidget); + }); + + testWidgets('hides publish button for published drafts', (tester) async { + await tester.pumpWidget(buildTestWidget(publishedDraft)); + expect(find.text('Publish to Store'), findsNothing); + }); + + testWidgets('calls onPublish when button is tapped', (tester) async { + var published = false; + await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true)); + await tester.tap(find.text('Publish to Store')); + expect(published, true); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart new file mode 100644 index 0000000..794f5ec --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:feature_wordpress/src/presentation/widgets/publish_status_chip.dart'; + +void main() { + Widget buildTestWidget(PublishStatus status) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold(body: PublishStatusChip(status: status)), + ); + } + + group('PublishStatusChip', () { + testWidgets('shows Draft label for draft status', (tester) async { + await tester.pumpWidget(buildTestWidget(PublishStatus.draft)); + expect(find.text('Draft'), findsOneWidget); + }); + + testWidgets('shows Pending Review label for pendingReview status', (tester) async { + await tester.pumpWidget(buildTestWidget(PublishStatus.pendingReview)); + expect(find.text('Pending Review'), findsOneWidget); + }); + + testWidgets('shows Published label for published status', (tester) async { + await tester.pumpWidget(buildTestWidget(PublishStatus.published)); + expect(find.text('Published'), findsOneWidget); + }); + + testWidgets('shows Unpublished label for unpublished status', (tester) async { + await tester.pumpWidget(buildTestWidget(PublishStatus.unpublished)); + expect(find.text('Unpublished'), findsOneWidget); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart new file mode 100644 index 0000000..62b97c9 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; + +void main() { + const mapper = WordPressProductMapper(); + + /// A minimal WooCommerce product JSON payload with all fields populated. + Map buildProductJson({ + int id = 42, + String name = 'Floral Bowl Cozy', + String description = '

A beautiful bowl cozy.

', + String price = '12.99', + String sku = 'BC-FLR-001', + String status = 'publish', + String dateModified = '2026-03-28T10:30:00', + String? dateCreated = '2026-03-20T08:00:00', + List>? categories, + List>? images, + }) { + return { + 'id': id, + 'name': name, + 'description': description, + 'price': price, + 'sku': sku, + 'status': status, + 'date_modified': dateModified, + 'date_created': dateCreated, + 'categories': + categories ?? + [ + {'id': 1, 'name': 'Bowl Cozies', 'slug': 'bowl-cozies'}, + ], + 'images': + images ?? + [ + {'id': 10, 'src': 'https://example.com/image.jpg', 'name': 'image.jpg'}, + ], + }; + } + + group('WordPressProductMapper.fromJson', () { + test('maps id as string', () { + final draft = mapper.fromJson(buildProductJson(id: 99)); + expect(draft.id, '99'); + }); + + test('maps name', () { + final draft = mapper.fromJson(buildProductJson(name: 'Test Product')); + expect(draft.name, 'Test Product'); + }); + + test('strips HTML from description', () { + final draft = mapper.fromJson( + buildProductJson(description: '

Hello world

'), + ); + expect(draft.description, 'Hello world'); + }); + + test('decodes HTML entities in description', () { + final draft = mapper.fromJson(buildProductJson(description: 'Tom & Jerry <3>')); + expect(draft.description, 'Tom & Jerry <3>'); + }); + + test('maps price from string', () { + final draft = mapper.fromJson(buildProductJson(price: '19.50')); + expect(draft.price, 19.50); + }); + + test('maps price from null to 0.0', () { + final json = buildProductJson(); + json['price'] = null; + final draft = mapper.fromJson(json); + expect(draft.price, 0.0); + }); + + test('maps sku', () { + final draft = mapper.fromJson(buildProductJson(sku: 'NL-OCN-003')); + expect(draft.sku, 'NL-OCN-003'); + }); + + test('maps first category name', () { + final draft = mapper.fromJson( + buildProductJson( + categories: [ + {'id': 1, 'name': 'Coasters', 'slug': 'coasters'}, + {'id': 2, 'name': 'Kitchen', 'slug': 'kitchen'}, + ], + ), + ); + expect(draft.category, 'Coasters'); + }); + + test('defaults to Uncategorized when categories is empty', () { + final draft = mapper.fromJson(buildProductJson(categories: [])); + expect(draft.category, 'Uncategorized'); + }); + + test('defaults to Uncategorized when categories is null', () { + final json = buildProductJson(); + json['categories'] = null; + final draft = mapper.fromJson(json); + expect(draft.category, 'Uncategorized'); + }); + + test('maps first image URL', () { + final draft = mapper.fromJson( + buildProductJson( + images: [ + {'id': 1, 'src': 'https://example.com/a.jpg'}, + {'id': 2, 'src': 'https://example.com/b.jpg'}, + ], + ), + ); + expect(draft.imageUrl, 'https://example.com/a.jpg'); + }); + + test('defaults to empty string when images is empty', () { + final draft = mapper.fromJson(buildProductJson(images: [])); + expect(draft.imageUrl, ''); + }); + + test('maps date_modified', () { + final draft = mapper.fromJson(buildProductJson(dateModified: '2026-04-01T14:00:00')); + expect(draft.lastModified, DateTime(2026, 4, 1, 14, 0, 0)); + }); + + test('falls back to date_created when date_modified is null', () { + final json = buildProductJson(dateCreated: '2026-03-15T09:00:00'); + json['date_modified'] = null; + final draft = mapper.fromJson(json); + expect(draft.lastModified, DateTime(2026, 3, 15, 9, 0, 0)); + }); + + group('status mapping', () { + test('publish -> PublishStatus.published', () { + final draft = mapper.fromJson(buildProductJson(status: 'publish')); + expect(draft.status, PublishStatus.published); + }); + + test('draft -> PublishStatus.draft', () { + final draft = mapper.fromJson(buildProductJson(status: 'draft')); + expect(draft.status, PublishStatus.draft); + }); + + test('pending -> PublishStatus.pendingReview', () { + final draft = mapper.fromJson(buildProductJson(status: 'pending')); + expect(draft.status, PublishStatus.pendingReview); + }); + + test('private -> PublishStatus.unpublished', () { + final draft = mapper.fromJson(buildProductJson(status: 'private')); + expect(draft.status, PublishStatus.unpublished); + }); + + test('trash -> PublishStatus.unpublished', () { + final draft = mapper.fromJson(buildProductJson(status: 'trash')); + expect(draft.status, PublishStatus.unpublished); + }); + + test('unknown status defaults to PublishStatus.draft', () { + final draft = mapper.fromJson(buildProductJson(status: 'future')); + expect(draft.status, PublishStatus.draft); + }); + }); + }); + + group('WordPressProductMapper.fromJsonList', () { + test('maps a list of JSON objects', () { + final jsonList = [ + buildProductJson(id: 1, name: 'Product A'), + buildProductJson(id: 2, name: 'Product B'), + buildProductJson(id: 3, name: 'Product C'), + ]; + + final drafts = mapper.fromJsonList(jsonList); + expect(drafts.length, 3); + expect(drafts[0].name, 'Product A'); + expect(drafts[1].name, 'Product B'); + expect(drafts[2].name, 'Product C'); + }); + + test('returns empty list for empty input', () { + final drafts = mapper.fromJsonList([]); + expect(drafts, isEmpty); + }); + }); + + group('Edge cases', () { + test('handles completely empty JSON map gracefully', () { + final draft = mapper.fromJson({}); + expect(draft.id, ''); + expect(draft.name, ''); + expect(draft.description, ''); + expect(draft.price, 0.0); + expect(draft.sku, ''); + expect(draft.category, 'Uncategorized'); + expect(draft.imageUrl, ''); + expect(draft.status, PublishStatus.draft); + }); + + test('handles numeric price value', () { + final json = buildProductJson(); + json['price'] = 25.0; + final draft = mapper.fromJson(json); + expect(draft.price, 25.0); + }); + + test('handles integer price value', () { + final json = buildProductJson(); + json['price'] = 10; + final draft = mapper.fromJson(json); + expect(draft.price, 10.0); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart new file mode 100644 index 0000000..fc0e378 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; + +void main() { + /// Builds a minimal WooCommerce product JSON list response. + List> buildProductsJson(int count) { + return List.generate(count, (i) { + return { + 'id': i + 1, + 'name': 'Product ${i + 1}', + 'description': '

Description ${i + 1}

', + 'price': '${(i + 1) * 10}.99', + 'sku': 'SKU-${i + 1}', + 'status': 'publish', + 'date_modified': '2026-04-01T10:00:00', + 'date_created': '2026-03-01T08:00:00', + 'categories': [ + {'id': 1, 'name': 'Test Category', 'slug': 'test-category'}, + ], + 'images': [ + {'id': 1, 'src': 'https://example.com/img${i + 1}.jpg'}, + ], + }; + }); + } + + group('WooCommerceApiClient', () { + test('getProducts returns parsed product list on 200', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/wp-json/wc/v3/products'); + expect(request.url.queryParameters['page'], '1'); + expect(request.url.queryParameters['per_page'], '100'); + expect(request.headers['Authorization'], startsWith('Basic ')); + + return http.Response(jsonEncode(buildProductsJson(3)), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final products = await apiClient.getProducts(); + expect(products.length, 3); + expect(products[0]['name'], 'Product 1'); + expect(products[2]['name'], 'Product 3'); + }); + + test('getProducts throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"unauthorized"}', 401); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_bad', + consumerSecret: 'cs_bad', + httpClient: mockClient, + ); + + expect(() => apiClient.getProducts(), throwsA(isA())); + }); + + test('getAllProducts paginates until a short page', () async { + var requestCount = 0; + final mockClient = MockClient((request) async { + requestCount++; + final page = int.parse(request.url.queryParameters['page']!); + // Page 1 returns 2 items (perPage=2), page 2 returns 1 item (short). + final count = page == 1 ? 2 : 1; + return http.Response(jsonEncode(buildProductsJson(count)), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final products = await apiClient.getAllProducts(perPage: 2); + expect(products.length, 3); + expect(requestCount, 2); + }); + }); + + group('WordPressProductPublishingRepository', () { + test('getProductDrafts returns mapped ProductDraft list', () async { + final mockClient = MockClient((request) async { + return http.Response(jsonEncode(buildProductsJson(2)), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + final drafts = await repository.getProductDrafts(); + expect(drafts.length, 2); + expect(drafts[0].id, '1'); + expect(drafts[0].name, 'Product 1'); + expect(drafts[0].description, 'Description 1'); + expect(drafts[0].price, 10.99); + expect(drafts[0].sku, 'SKU-1'); + expect(drafts[0].category, 'Test Category'); + expect(drafts[0].imageUrl, 'https://example.com/img1.jpg'); + expect(drafts[0].status, PublishStatus.published); + expect(drafts[1].id, '2'); + expect(drafts[1].name, 'Product 2'); + }); + + test('publishDraft throws UnimplementedError', () { + final mockClient = MockClient((request) async { + return http.Response('[]', 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + expect(() => repository.publishDraft('1'), throwsA(isA())); + }); + + test('getProductDrafts propagates API errors', () async { + final mockClient = MockClient((request) async { + return http.Response('Server Error', 500); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + expect(() => repository.getProductDrafts(), throwsA(isA())); + }); + }); +}