From 9eafc68fec2875f705f9ba4aec66e0d9da8eb0ec Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 22 May 2026 09:57:51 -0400 Subject: [PATCH] feat(core): extract shared composition pattern into core package (Stage 4B) Extract AppConfig/AppEnvironment, AppServices, Bootstrap, and AppScope into core package as KcAppConfig, KcAppServices, KcBootstrap, and KcAppScope generic abstractions. New core composition types: - KcAppConfig: runtime config from --dart-define (KC_ENV, WC credentials) - KcAppEnvironment: enum for fake/wordpress environments - KcAppServices: abstract base for app service containers - KcServiceFactory: generic factory for fake/wordpress service creation - KcBootstrap: shared bootstrap with env switch and WP credential fallback - KcAppScope: InheritedWidget exposing typed services + config to tree kell_web backward compatibility: - AppConfig/AppEnvironment are now typedefs to Kc-prefixed types - AppServices extends KcAppServices with concrete repositories - AppScope extends KcAppScope with direct InheritedWidget lookup - Bootstrap delegates to KcBootstrap.run with app-specific factory Tests: 20 new core tests, all 379 tests passing (core 20, design_system 41, feature_wordpress 294, kell_web 24). dart analyze clean. --- docs/development/master_development_brief.md | 11 +- .../kell_web/lib/composition/app_config.dart | 91 +----- .../kell_web/lib/composition/app_scope.dart | 40 ++- .../lib/composition/app_services.dart | 21 +- .../kell_web/lib/composition/bootstrap.dart | 68 ++-- .../docs/composition-strategy.md | 204 ++++++++++++ .../packages/core/lib/core.dart | 22 +- .../lib/src/composition/kc_app_config.dart | 105 +++++++ .../lib/src/composition/kc_app_scope.dart | 60 ++++ .../lib/src/composition/kc_app_services.dart | 67 ++++ .../lib/src/composition/kc_bootstrap.dart | 69 ++++ .../packages/core/test/core_test.dart | 295 +++++++++++++++++- 12 files changed, 901 insertions(+), 152 deletions(-) create mode 100644 kell_creations_apps/docs/composition-strategy.md create mode 100644 kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart create mode 100644 kell_creations_apps/packages/core/lib/src/composition/kc_app_scope.dart create mode 100644 kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart create mode 100644 kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 21bb4e6..5577ae9 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -93,6 +93,7 @@ Rules: - ✅ Multi-select groundwork landed (Stage 3A complete — merged `feat/multi-select-groundwork` → `main`, 2026-05-22). - ✅ List efficiency improvements landed (Stage 3B complete — merged `feat/list-efficiency-improvements` → `main`, 2026-05-22). Stage 3 complete. - ✅ Design system expansion and shared widget migration landed (Stage 4A complete — merged `feat/design-system-shared-widgets` → `main`, 2026-05-22). +- ✅ Cross-platform shell composition strategy landed (Stage 4B complete — merged `feat/shared-composition-pattern` → `main`, 2026-05-22). ### Current narrow edit capabilities on `main` @@ -105,18 +106,20 @@ Rules: ### Latest known validation state on `main` - `dart analyze` clean +- `core` tests passing - `design_system` tests passing - `feature_wordpress` tests passing - `kell_web` tests passing +- latest reported count for `core`: `20/20 passed` - latest reported count for `design_system`: `41/41 passed` - latest reported count for `feature_wordpress`: `294/294 passed` - latest reported count for `kell_web`: `24/24 passed` -- baseline commit: merge of `feat/design-system-shared-widgets` (2026-05-22) +- baseline commit: merge of `feat/shared-composition-pattern` (2026-05-22) ### Next recommended branch -**`feat/flutter-cicd`** — Stage 4B: Cross-platform shell composition strategy. -Branch from latest `main`. Stage 4A (design system expansion) is complete. +**`feat/flutter-cicd`** — Stage 4C: Flutter CI/CD pipeline. +Branch from latest `main`. Stage 4B (cross-platform shell composition) is complete. --- @@ -591,4 +594,4 @@ Working rules: | `data` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** | | `integrations` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** | | `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Foundation ready** | -| `core` | ✅ Partial | N/A | N/A | N/A | N/A | Minimal | **Foundation only** | +| `core` | ✅ Partial | N/A | N/A | N/A | N/A | 20 | **Foundation ready** | 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 index 340d33a..bf0486c 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart @@ -1,85 +1,18 @@ -/// Runtime configuration read from `--dart-define` values. +/// Re-exports [KcAppConfig] and [KcAppEnvironment] from the shared `core` package. /// -/// The app reads the following compile-time constants: +/// This file preserves backward compatibility for existing imports within +/// `kell_web`. New code should import directly from `package:core/core.dart`. /// -/// | 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; +/// The original class names are preserved as typedefs so that existing +/// references continue to compile without changes. +library; - /// WordPress / WooCommerce site URL (only used in [AppEnvironment.wordpress]). - final String wcSiteUrl; +export 'package:core/core.dart' show KcAppConfig, KcAppEnvironment; - /// WooCommerce REST API consumer key. - final String wcConsumerKey; +import 'package:core/core.dart'; - /// WooCommerce REST API consumer secret. - final String wcConsumerSecret; +/// @Deprecated('Use KcAppConfig from core instead.') +typedef AppConfig = KcAppConfig; - 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; - } -} +/// @Deprecated('Use KcAppEnvironment from core instead.') +typedef AppEnvironment = KcAppEnvironment; 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 index 577b756..d67386c 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart @@ -1,18 +1,30 @@ +/// Re-exports [KcAppScope] from the shared `core` package and provides +/// convenience accessors typed to [AppServices]. +/// +/// This file preserves backward compatibility for existing imports within +/// `kell_web`. New code should use [KcAppScope] from `package:core/core.dart` +/// directly. +library; + +import 'package:core/core.dart'; 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; +export 'package:core/core.dart' show KcAppScope; - const AppScope({super.key, required this.services, required this.config, required super.child}); +/// App-specific convenience wrapper around [KcAppScope] for `kell_web`. +/// +/// Preserves the original `AppScope.of(context)` and `AppScope.configOf(context)` +/// call sites so existing code continues to work without modification. +/// +/// New code should prefer: +/// ```dart +/// KcAppScope.of(context) +/// KcAppScope.configOf(context) +/// ``` +class AppScope extends KcAppScope { + const AppScope({super.key, required super.services, required super.config, required super.child}); /// Returns the nearest [AppServices] from the widget tree. /// @@ -23,16 +35,12 @@ class AppScope extends InheritedWidget { return scope!.services; } - /// Returns the nearest [AppConfig] from the widget tree. + /// Returns the nearest [KcAppConfig] from the widget tree. /// /// Throws if no [AppScope] ancestor is found. - static AppConfig configOf(BuildContext context) { + static KcAppConfig 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 index d0f9b34..2646c16 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart @@ -1,15 +1,20 @@ +import 'package:core/core.dart'; 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. +/// Holds the concrete service implementations used by `kell_web`. +/// +/// Extends [KcAppServices] from the shared `core` package so that the +/// generic [KcBootstrap] and [KcAppScope] infrastructure can work with +/// this app's specific service set. /// /// 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 { +class AppServices extends KcAppServices { final InventoryRepository inventoryRepository; final OrdersRepository ordersRepository; final PolicyRepository policyRepository; @@ -57,4 +62,16 @@ class AppServices { productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), ); } + + /// Returns a [KcServiceFactory] for use with [KcBootstrap.run]. + static KcServiceFactory get serviceFactory { + return KcServiceFactory( + createFake: () => AppServices.fake(), + createWordPress: (config) => AppServices.wordpress( + siteUrl: config.wcSiteUrl, + consumerKey: config.wcConsumerKey, + consumerSecret: config.wcConsumerSecret, + ), + ); + } } diff --git a/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart b/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart index 8fc3dfa..69bc3f7 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart @@ -1,15 +1,33 @@ -import 'package:flutter/foundation.dart'; +/// Re-exports [KcBootstrap] from the shared `core` package and provides +/// a backward-compatible [Bootstrap] wrapper for `kell_web`. +/// +/// This file preserves the existing `Bootstrap.run(config)` call site. +/// New code should use [KcBootstrap.run] from `package:core/core.dart` +/// with a [KcServiceFactory] directly. +library; + +import 'package:core/core.dart'; -import 'app_config.dart'; import 'app_services.dart'; -/// Bootstraps [AppServices] from the runtime [AppConfig]. +export 'package:core/core.dart' show KcBootstrap; + +/// Backward-compatible bootstrap for `kell_web`. /// -/// In **fake** mode the in-memory fakes are used unconditionally. +/// Delegates to [KcBootstrap.run] using the [AppServices.serviceFactory]. /// -/// 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. +/// Existing call sites: +/// ```dart +/// final (:services, config: effectiveConfig) = Bootstrap.run(config); +/// ``` +/// +/// New code should prefer: +/// ```dart +/// final (:services, config: effectiveConfig) = KcBootstrap.run( +/// config, +/// AppServices.serviceFactory, +/// ); +/// ``` class Bootstrap { const Bootstrap._(); @@ -18,39 +36,7 @@ class Bootstrap { /// 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, - ); - } + static ({AppServices services, KcAppConfig config}) run(KcAppConfig config) { + return KcBootstrap.run(config, AppServices.serviceFactory); } } diff --git a/kell_creations_apps/docs/composition-strategy.md b/kell_creations_apps/docs/composition-strategy.md new file mode 100644 index 0000000..d63c035 --- /dev/null +++ b/kell_creations_apps/docs/composition-strategy.md @@ -0,0 +1,204 @@ +# Kell Creations — Cross-Platform Shell Composition Strategy + +## Overview + +This document describes the shared composition pattern that all Kell Creations +app targets (`kell_web`, `kell_mobile`, future targets) must follow. The pattern +lives in the `core` package and provides a consistent way to: + +1. Read runtime configuration from `--dart-define` values. +2. Construct environment-appropriate services (fake vs. real backends). +3. Expose services and configuration to the widget tree. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ core (shared package) │ +│ │ +│ KcAppConfig / KcAppEnvironment │ +│ └─ Runtime config from --dart-define │ +│ │ +│ KcAppServices (abstract) │ +│ └─ Base class for app service containers │ +│ │ +│ KcServiceFactory │ +│ └─ Generic factory: createFake() / createWordPress(cfg) │ +│ │ +│ KcBootstrap │ +│ └─ Shared bootstrap: env switch + WP credential fallback │ +│ │ +│ KcAppScope │ +│ └─ InheritedWidget exposing T + KcAppConfig to tree │ +└──────────────────────────────────────────────────────────────┘ + ▲ ▲ + │ │ +┌────────┴───────────┐ ┌────────────┴──────────────┐ +│ kell_web (app) │ │ kell_mobile (app) │ +│ │ │ │ +│ AppServices │ │ MobileAppServices │ +│ extends │ │ extends │ +│ KcAppServices │ │ KcAppServices │ +│ │ │ │ +│ AppScope │ │ (uses KcAppScope │ +│ extends │ │ ) │ +│ KcAppScope │ │ │ +│ │ │ │ +│ │ │ │ +│ Bootstrap │ │ (uses KcBootstrap.run │ +│ delegates to │ │ with own factory) │ +│ KcBootstrap.run │ │ │ +└─────────────────────┘ └────────────────────────────┘ +``` + +## Why abstract services? + +Concrete service containers hold references to feature-package repository types +(e.g., `InventoryRepository`, `ProductPublishingRepository`). Moving those +references into `core` would create **circular dependencies**: + +``` +core → feature_wordpress → core ← NOT ALLOWED +``` + +By keeping `KcAppServices` abstract in `core`, the package owns the composition +**contract** without knowing concrete types. Each app provides its own concrete +subclass. + +## Contract for new app targets + +When creating a new app target (e.g., `kell_mobile`), follow this pattern: + +### 1. Create a concrete services class + +```dart +// apps/kell_mobile/lib/composition/mobile_app_services.dart +import 'package:core/core.dart'; +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; +// ... other feature imports + +class MobileAppServices extends KcAppServices { + final InventoryRepository inventoryRepository; + final ProductPublishingRepository productPublishingRepository; + // ... other repositories as needed + + const MobileAppServices({ + required this.inventoryRepository, + required this.productPublishingRepository, + }); + + factory MobileAppServices.fake() { + return MobileAppServices( + inventoryRepository: FakeInventoryRepository(), + productPublishingRepository: FakeProductPublishingRepository(), + ); + } + + factory MobileAppServices.wordpress({ + required String siteUrl, + required String consumerKey, + required String consumerSecret, + }) { + final apiClient = WooCommerceApiClient( + siteUrl: siteUrl, + consumerKey: consumerKey, + consumerSecret: consumerSecret, + ); + return MobileAppServices( + inventoryRepository: FakeInventoryRepository(), + productPublishingRepository: + WordPressProductPublishingRepository(apiClient: apiClient), + ); + } + + static KcServiceFactory get serviceFactory { + return KcServiceFactory( + createFake: () => MobileAppServices.fake(), + createWordPress: (config) => MobileAppServices.wordpress( + siteUrl: config.wcSiteUrl, + consumerKey: config.wcConsumerKey, + consumerSecret: config.wcConsumerSecret, + ), + ); + } +} +``` + +### 2. Wire up main.dart + +```dart +// apps/kell_mobile/lib/main.dart +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; + +import 'composition/mobile_app_services.dart'; + +void main() { + final config = KcAppConfig.fromEnvironment(); + final (:services, config: effectiveConfig) = KcBootstrap.run( + config, + MobileAppServices.serviceFactory, + ); + + runApp( + KcAppScope( + services: services, + config: effectiveConfig, + child: const KellMobileApp(), + ), + ); +} +``` + +### 3. Access services in widgets + +```dart +// Anywhere in the widget tree: +final services = KcAppScope.of(context); +final config = KcAppScope.configOf(context); +``` + +## Backward compatibility (kell_web) + +The `kell_web` app retains its original class names (`AppConfig`, `AppScope`, +`Bootstrap`, `AppServices`) as thin wrappers/typedefs around the shared `core` +types. Existing code continues to work without modification: + +| Original (kell_web) | Shared (core) | Relationship | +| ------------------- | ------------------ | --------------- | +| `AppConfig` | `KcAppConfig` | typedef | +| `AppEnvironment` | `KcAppEnvironment` | typedef | +| `AppServices` | `KcAppServices` | extends | +| `AppScope` | `KcAppScope` | extends (typed) | +| `Bootstrap` | `KcBootstrap` | delegates | + +New code in `kell_web` may use either the original names or the shared `Kc`-prefixed +names. The shared names are preferred for clarity and consistency with `kell_mobile`. + +## Runtime configuration + +All app targets use the same `--dart-define` keys: + +| 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) | + +When `KC_ENV=wordpress` but credentials are missing, `KcBootstrap` automatically +falls back to fake mode with a debug-mode warning. + +## What remains app-specific + +The following concerns are **not** shared and remain in each app target: + +- **App shell / navigation** — Web uses `NavigationRail`, mobile will use + `BottomNavigationBar` or similar. +- **Routing** — Route definitions and page builders are platform-specific. +- **Platform-specific presentation** — Layout, responsive breakpoints, etc. +- **Cross-feature navigation handoffs** — Wired in the app's routing layer. + +The `design_system` package provides shared visual components (widgets, theme, +typography, breakpoints) that both web and mobile can use for consistent styling. diff --git a/kell_creations_apps/packages/core/lib/core.dart b/kell_creations_apps/packages/core/lib/core.dart index 298576d..4c90e84 100644 --- a/kell_creations_apps/packages/core/lib/core.dart +++ b/kell_creations_apps/packages/core/lib/core.dart @@ -1,5 +1,17 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +/// Core shared abstractions for Kell Creations applications. +/// +/// This package provides the platform-agnostic composition pattern used by +/// all app targets (`kell_web`, `kell_mobile`, etc.): +/// +/// - [KcAppConfig] / [KcAppEnvironment] — runtime configuration from `--dart-define` +/// - [KcAppServices] — abstract service container base class +/// - [KcServiceFactory] — generic factory for environment-aware service construction +/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation +/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree +library; + +// Composition +export 'src/composition/kc_app_config.dart'; +export 'src/composition/kc_app_scope.dart'; +export 'src/composition/kc_app_services.dart'; +export 'src/composition/kc_bootstrap.dart'; diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart new file mode 100644 index 0000000..276684a --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart @@ -0,0 +1,105 @@ +/// Runtime configuration read from `--dart-define` values. +/// +/// This is the shared, platform-agnostic configuration model used by all +/// Kell Creations app targets (`kell_web`, `kell_mobile`, etc.). +/// +/// 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 KcAppConfig { + /// The environment mode: `fake` or `wordpress`. + final KcAppEnvironment environment; + + /// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]). + final String wcSiteUrl; + + /// WooCommerce REST API consumer key. + final String wcConsumerKey; + + /// WooCommerce REST API consumer secret. + final String wcConsumerSecret; + + const KcAppConfig({ + required this.environment, + required this.wcSiteUrl, + required this.wcConsumerKey, + required this.wcConsumerSecret, + }); + + /// Reads configuration from compile-time `--dart-define` constants. + factory KcAppConfig.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 = KcAppEnvironment.fromString(envString); + + return KcAppConfig( + 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; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is KcAppConfig && + environment == other.environment && + wcSiteUrl == other.wcSiteUrl && + wcConsumerKey == other.wcConsumerKey && + wcConsumerSecret == other.wcConsumerSecret; + + @override + int get hashCode => Object.hash(environment, wcSiteUrl, wcConsumerKey, wcConsumerSecret); + + @override + String toString() => + 'KcAppConfig(environment: ${environment.key}, ' + 'hasWordPressConfig: $hasWordPressConfig)'; +} + +/// The supported runtime environments. +enum KcAppEnvironment { + /// In-memory fakes – no network calls. + fake('fake', 'FAKE'), + + /// Real WooCommerce backend. + wordpress('wordpress', 'WP'); + + final String key; + final String label; + + const KcAppEnvironment(this.key, this.label); + + /// Parses a string into a [KcAppEnvironment], defaulting to [fake]. + static KcAppEnvironment fromString(String value) { + for (final env in values) { + if (env.key == value.toLowerCase()) return env; + } + return fake; + } +} diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_app_scope.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_app_scope.dart new file mode 100644 index 0000000..85b13ff --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_app_scope.dart @@ -0,0 +1,60 @@ +/// Shared [InheritedWidget] that exposes [KcAppServices] and [KcAppConfig] +/// to the widget tree. +/// +/// Both `kell_web` and `kell_mobile` use this widget at the root of their +/// widget tree so that any descendant can retrieve services and configuration. +library; + +import 'package:flutter/widgets.dart'; + +import 'kc_app_config.dart'; +import 'kc_app_services.dart'; + +/// An [InheritedWidget] that exposes [KcAppServices] and [KcAppConfig] to the +/// widget tree. +/// +/// Wrap the app (or a subtree) with [KcAppScope] and retrieve the services +/// anywhere below via [KcAppScope.of(context)]. +/// +/// The type parameter [T] allows each app target to retrieve its concrete +/// services subclass without casting: +/// +/// ```dart +/// // In kell_web: +/// final services = KcAppScope.of(context); +/// services.inventoryRepository; // ← strongly typed +/// ``` +class KcAppScope extends InheritedWidget { + /// The concrete services for this app target. + final T services; + + /// The effective runtime configuration. + final KcAppConfig config; + + const KcAppScope({super.key, required this.services, required this.config, required super.child}); + + /// Returns the nearest [T] (concrete services) from the widget tree. + /// + /// Throws if no [KcAppScope] ancestor is found. + static T of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType>(); + assert(scope != null, 'No KcAppScope<$T> found in the widget tree'); + return scope!.services; + } + + /// Returns the nearest [KcAppConfig] from the widget tree. + /// + /// This is a convenience accessor that works regardless of the concrete + /// services type — it looks for any [KcAppScope] with any type parameter. + /// + /// For apps that know their concrete type, prefer using the typed overload. + static KcAppConfig configOf(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType>(); + assert(scope != null, 'No KcAppScope<$T> found in the widget tree'); + return scope!.config; + } + + @override + bool updateShouldNotify(KcAppScope oldWidget) => + services != oldWidget.services || config != oldWidget.config; +} diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart new file mode 100644 index 0000000..659711f --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart @@ -0,0 +1,67 @@ +/// Shared composition abstractions for app service containers. +/// +/// Provides [KcAppServices] as an abstract base and [KcServiceFactory] as +/// the generic factory pattern used by [KcBootstrap] to construct services. +library; + +import 'kc_app_config.dart'; + +/// Abstract base class for application service containers. +/// +/// Each app target (`kell_web`, `kell_mobile`) creates a concrete subclass +/// that holds the specific repository and service implementations needed by +/// that target. The base class defines the factory contract that +/// [KcBootstrap] uses to construct services from a [KcAppConfig]. +/// +/// ## Pattern +/// +/// ```dart +/// class WebAppServices extends KcAppServices { +/// final InventoryRepository inventoryRepository; +/// final ProductPublishingRepository productPublishingRepository; +/// // ... +/// +/// const WebAppServices({ +/// required this.inventoryRepository, +/// required this.productPublishingRepository, +/// }); +/// } +/// ``` +/// +/// ## Why abstract? +/// +/// Concrete service containers reference feature-package repository types +/// directly. Moving those references into `core` would create circular +/// dependencies (`core` → feature → `core`). By keeping the base abstract, +/// `core` owns the composition *contract* without knowing concrete types. +abstract class KcAppServices { + const KcAppServices(); +} + +/// A factory that constructs the appropriate [KcAppServices] subclass +/// from a [KcAppConfig]. +/// +/// Each app target provides two factories — one for fake mode and one for +/// the real backend — via [KcServiceFactory]. +/// +/// ```dart +/// final factory = KcServiceFactory( +/// createFake: () => WebAppServices.fake(), +/// createWordPress: (config) => WebAppServices.wordpress( +/// siteUrl: config.wcSiteUrl, +/// consumerKey: config.wcConsumerKey, +/// consumerSecret: config.wcConsumerSecret, +/// ), +/// ); +/// ``` +class KcServiceFactory { + /// Creates services backed by fake, in-memory repositories. + final T Function() createFake; + + /// Creates services backed by the real WordPress/WooCommerce backend. + /// + /// Receives the full [KcAppConfig] so the factory can extract credentials. + final T Function(KcAppConfig config) createWordPress; + + const KcServiceFactory({required this.createFake, required this.createWordPress}); +} diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart new file mode 100644 index 0000000..03803c7 --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart @@ -0,0 +1,69 @@ +/// Shared bootstrap logic for all Kell Creations app targets. +/// +/// Uses a [KcServiceFactory] to construct the appropriate services based +/// on the runtime [KcAppConfig]. +library; + +import 'package:flutter/foundation.dart'; + +import 'kc_app_config.dart'; +import 'kc_app_services.dart'; + +/// Bootstraps app services from the runtime [KcAppConfig]. +/// +/// 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. +/// +/// ## Usage +/// +/// ```dart +/// final factory = KcServiceFactory( +/// createFake: () => WebAppServices.fake(), +/// createWordPress: (config) => WebAppServices.wordpress(...), +/// ); +/// +/// final (:services, config: effectiveConfig) = KcBootstrap.run(config, factory); +/// ``` +class KcBootstrap { + const KcBootstrap._(); + + /// Creates the appropriate services for the given [config] using [factory]. + /// + /// 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 ({T services, KcAppConfig config}) run( + KcAppConfig config, + KcServiceFactory factory, + ) { + switch (config.environment) { + case KcAppEnvironment.fake: + return (services: factory.createFake(), config: config); + + case KcAppEnvironment.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 = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: factory.createFake(), config: fallbackConfig); + } + + return (services: factory.createWordPress(config), config: config); + } + } +} diff --git a/kell_creations_apps/packages/core/test/core_test.dart b/kell_creations_apps/packages/core/test/core_test.dart index 66d6985..5977a97 100644 --- a/kell_creations_apps/packages/core/test/core_test.dart +++ b/kell_creations_apps/packages/core/test/core_test.dart @@ -1,12 +1,297 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:core/core.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('KcAppConfig', () { + test('creates config with required fields', () { + const config = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + expect(config.environment, KcAppEnvironment.fake); + expect(config.environmentLabel, 'FAKE'); + expect(config.hasWordPressConfig, isFalse); + }); + + test('hasWordPressConfig returns true when all credentials present', () { + const config = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: 'https://example.com', + wcConsumerKey: 'ck_test', + wcConsumerSecret: 'cs_test', + ); + + expect(config.hasWordPressConfig, isTrue); + expect(config.environmentLabel, 'WP'); + }); + + test('hasWordPressConfig returns false when any credential is missing', () { + const config = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: 'https://example.com', + wcConsumerKey: '', + wcConsumerSecret: 'cs_test', + ); + + expect(config.hasWordPressConfig, isFalse); + }); + + test('equality compares all fields', () { + const a = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + const b = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + const c = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('toString includes environment and hasWordPressConfig', () { + const config = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + expect(config.toString(), contains('fake')); + expect(config.toString(), contains('hasWordPressConfig: false')); + }); + }); + + group('KcAppEnvironment', () { + test('fromString parses known values', () { + expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake); + expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress); + }); + + test('fromString is case-insensitive', () { + expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake); + expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress); + }); + + test('fromString defaults to fake for unknown values', () { + expect(KcAppEnvironment.fromString('unknown'), KcAppEnvironment.fake); + expect(KcAppEnvironment.fromString(''), KcAppEnvironment.fake); + }); + + test('label returns expected display string', () { + expect(KcAppEnvironment.fake.label, 'FAKE'); + expect(KcAppEnvironment.wordpress.label, 'WP'); + }); + + test('key returns expected raw string', () { + expect(KcAppEnvironment.fake.key, 'fake'); + expect(KcAppEnvironment.wordpress.key, 'wordpress'); + }); + }); + + group('KcAppServices', () { + test('abstract base can be extended', () { + final services = _TestAppServices(); + expect(services, isA()); + }); + }); + + group('KcServiceFactory', () { + test('createFake returns fake services', () { + final factory = KcServiceFactory<_TestAppServices>( + createFake: () => _TestAppServices(mode: 'fake'), + createWordPress: (config) => _TestAppServices(mode: 'wp'), + ); + + final services = factory.createFake(); + expect(services.mode, 'fake'); + }); + + test('createWordPress returns wordpress services', () { + const config = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: 'https://example.com', + wcConsumerKey: 'ck_test', + wcConsumerSecret: 'cs_test', + ); + final factory = KcServiceFactory<_TestAppServices>( + createFake: () => _TestAppServices(mode: 'fake'), + createWordPress: (cfg) => _TestAppServices(mode: 'wp-${cfg.wcSiteUrl}'), + ); + + final services = factory.createWordPress(config); + expect(services.mode, 'wp-https://example.com'); + }); + }); + + group('KcBootstrap', () { + late KcServiceFactory<_TestAppServices> factory; + + setUp(() { + factory = KcServiceFactory<_TestAppServices>( + createFake: () => _TestAppServices(mode: 'fake'), + createWordPress: (config) => _TestAppServices(mode: 'wp'), + ); + }); + + test('fake environment returns fake services', () { + const config = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + }); + + test('wordpress environment with valid credentials returns wp services', () { + const config = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: 'https://example.com', + wcConsumerKey: 'ck_test', + wcConsumerSecret: 'cs_test', + ); + + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'wp'); + expect(result.config.environment, KcAppEnvironment.wordpress); + }); + + test('wordpress environment without credentials falls back to fake', () { + const config = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + }); + + test('wordpress fallback preserves partial credentials in config', () { + const config = KcAppConfig( + environment: KcAppEnvironment.wordpress, + wcSiteUrl: 'https://example.com', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + expect(result.config.wcSiteUrl, 'https://example.com'); + }); + }); + + group('KcAppScope', () { + testWidgets('of retrieves typed services from widget tree', (tester) async { + final services = _TestAppServices(mode: 'test'); + const config = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + late _TestAppServices retrievedServices; + late KcAppConfig retrievedConfig; + + await tester.pumpWidget( + KcAppScope<_TestAppServices>( + services: services, + config: config, + child: Builder( + builder: (context) { + retrievedServices = KcAppScope.of<_TestAppServices>(context); + retrievedConfig = KcAppScope.configOf<_TestAppServices>(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(retrievedServices.mode, 'test'); + expect(retrievedConfig.environment, KcAppEnvironment.fake); + }); + + testWidgets('updateShouldNotify returns true when services change', (tester) async { + final scope1 = KcAppScope<_TestAppServices>( + services: _TestAppServices(mode: 'a'), + config: const KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ), + child: const SizedBox.shrink(), + ); + final scope2 = KcAppScope<_TestAppServices>( + services: _TestAppServices(mode: 'b'), + config: const KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ), + child: const SizedBox.shrink(), + ); + + expect(scope1.updateShouldNotify(scope2), isTrue); + }); + + testWidgets('updateShouldNotify returns false when identical', (tester) async { + final services = _TestAppServices(mode: 'same'); + const config = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + final scope1 = KcAppScope<_TestAppServices>( + services: services, + config: config, + child: const SizedBox.shrink(), + ); + final scope2 = KcAppScope<_TestAppServices>( + services: services, + config: config, + child: const SizedBox.shrink(), + ); + + expect(scope1.updateShouldNotify(scope2), isFalse); + }); }); } + +/// A minimal concrete [KcAppServices] subclass for testing. +class _TestAppServices extends KcAppServices { + final String mode; + const _TestAppServices({this.mode = 'default'}); +}