Compare commits

...

2 Commits

Author SHA1 Message Date
Mike Kell 0a0abc2c3d Merge feat/shared-composition-pattern into main (Stage 4B complete)
Publish Docs / publish-docs (push) Successful in 58s Details
2026-05-22 09:57:59 -04:00
Mike Kell 9eafc68fec feat(core): extract shared composition pattern into core package (Stage 4B)
Validate Docs / validate-docs (push) Successful in 1m13s Details
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<T>: generic factory for fake/wordpress service creation

- KcBootstrap: shared bootstrap with env switch and WP credential fallback

- KcAppScope<T>: 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<AppServices> 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.
2026-05-22 09:57:51 -04:00
12 changed files with 901 additions and 152 deletions

View File

@ -93,6 +93,7 @@ Rules:
- ✅ Multi-select groundwork landed (Stage 3A complete — merged `feat/multi-select-groundwork``main`, 2026-05-22). - ✅ 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. - ✅ 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). - ✅ 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` ### Current narrow edit capabilities on `main`
@ -105,18 +106,20 @@ Rules:
### Latest known validation state on `main` ### Latest known validation state on `main`
- `dart analyze` clean - `dart analyze` clean
- `core` tests passing
- `design_system` tests passing - `design_system` tests passing
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `kell_web` 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 `design_system`: `41/41 passed`
- latest reported count for `feature_wordpress`: `294/294 passed` - latest reported count for `feature_wordpress`: `294/294 passed`
- latest reported count for `kell_web`: `24/24 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 ### Next recommended branch
**`feat/flutter-cicd`** — Stage 4B: Cross-platform shell composition strategy. **`feat/flutter-cicd`** — Stage 4C: Flutter CI/CD pipeline.
Branch from latest `main`. Stage 4A (design system expansion) is complete. 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** | | `data` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** |
| `integrations` | ❌ 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** | | `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** |

View File

@ -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 | /// The original class names are preserved as typedefs so that existing
/// |------------------------|----------------------------------------------|----------| /// references continue to compile without changes.
/// | `KC_ENV` | `fake` or `wordpress` | `fake` | library;
/// | `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]). export 'package:core/core.dart' show KcAppConfig, KcAppEnvironment;
final String wcSiteUrl;
/// WooCommerce REST API consumer key. import 'package:core/core.dart';
final String wcConsumerKey;
/// WooCommerce REST API consumer secret. /// @Deprecated('Use KcAppConfig from core instead.')
final String wcConsumerSecret; typedef AppConfig = KcAppConfig;
const AppConfig({ /// @Deprecated('Use KcAppEnvironment from core instead.')
required this.environment, typedef AppEnvironment = KcAppEnvironment;
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;
}
}

View File

@ -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 'package:flutter/widgets.dart';
import 'app_config.dart';
import 'app_services.dart'; import 'app_services.dart';
/// An [InheritedWidget] that exposes [AppServices] and [AppConfig] to the export 'package:core/core.dart' show KcAppScope;
/// 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}); /// 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<AppServices>(context)
/// KcAppScope.configOf<AppServices>(context)
/// ```
class AppScope extends KcAppScope<AppServices> {
const AppScope({super.key, required super.services, required super.config, required super.child});
/// Returns the nearest [AppServices] from the widget tree. /// Returns the nearest [AppServices] from the widget tree.
/// ///
@ -23,16 +35,12 @@ class AppScope extends InheritedWidget {
return scope!.services; 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. /// Throws if no [AppScope] ancestor is found.
static AppConfig configOf(BuildContext context) { static KcAppConfig configOf(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>(); final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'No AppScope found in the widget tree'); assert(scope != null, 'No AppScope found in the widget tree');
return scope!.config; return scope!.config;
} }
@override
bool updateShouldNotify(AppScope oldWidget) =>
services != oldWidget.services || config != oldWidget.config;
} }

View File

@ -1,15 +1,20 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart'; import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart'; import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart'; import 'package:feature_policy/feature_policy.dart';
import 'package:feature_wordpress/feature_wordpress.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 /// The [AppServices.fake] factory wires up the in-memory fakes that live
/// inside each feature package. The [AppServices.wordpress] factory wires up /// inside each feature package. The [AppServices.wordpress] factory wires up
/// a real WooCommerce-backed product repository while keeping other services /// a real WooCommerce-backed product repository while keeping other services
/// fake until their backends are ready. /// fake until their backends are ready.
class AppServices { class AppServices extends KcAppServices {
final InventoryRepository inventoryRepository; final InventoryRepository inventoryRepository;
final OrdersRepository ordersRepository; final OrdersRepository ordersRepository;
final PolicyRepository policyRepository; final PolicyRepository policyRepository;
@ -57,4 +62,16 @@ class AppServices {
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
); );
} }
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
static KcServiceFactory<AppServices> get serviceFactory {
return KcServiceFactory<AppServices>(
createFake: () => AppServices.fake(),
createWordPress: (config) => AppServices.wordpress(
siteUrl: config.wcSiteUrl,
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
);
}
} }

View File

@ -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'; 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. /// Existing call sites:
/// If any credential is missing the app falls back to fake mode and logs a /// ```dart
/// warning so the developer knows what went wrong. /// final (:services, config: effectiveConfig) = Bootstrap.run(config);
/// ```
///
/// New code should prefer:
/// ```dart
/// final (:services, config: effectiveConfig) = KcBootstrap.run(
/// config,
/// AppServices.serviceFactory,
/// );
/// ```
class Bootstrap { class Bootstrap {
const Bootstrap._(); const Bootstrap._();
@ -18,39 +36,7 @@ class Bootstrap {
/// Returns a record containing the resolved services and the effective /// Returns a record containing the resolved services and the effective
/// config (which may differ from the input when WordPress credentials are /// config (which may differ from the input when WordPress credentials are
/// missing and a fallback to fake mode occurs). /// missing and a fallback to fake mode occurs).
static ({AppServices services, AppConfig config}) run(AppConfig config) { static ({AppServices services, KcAppConfig config}) run(KcAppConfig config) {
switch (config.environment) { return KcBootstrap.run<AppServices>(config, AppServices.serviceFactory);
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,
);
}
} }
} }

View File

@ -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<T extends KcAppServices>
│ └─ Generic factory: createFake() / createWordPress(cfg) │
│ │
│ KcBootstrap │
│ └─ Shared bootstrap: env switch + WP credential fallback │
│ │
│ KcAppScope<T extends KcAppServices>
│ └─ InheritedWidget exposing T + KcAppConfig to tree │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ │
┌────────┴───────────┐ ┌────────────┴──────────────┐
│ kell_web (app) │ │ kell_mobile (app) │
│ │ │ │
│ AppServices │ │ MobileAppServices │
│ extends │ │ extends │
│ KcAppServices │ │ KcAppServices │
│ │ │ │
│ AppScope │ │ (uses KcAppScope │
│ extends │ │ <MobileAppServices>) │
│ KcAppScope │ │ │
<AppServices> │ │ │
│ │ │ │
│ 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<MobileAppServices> get serviceFactory {
return KcServiceFactory<MobileAppServices>(
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<MobileAppServices>(
services: services,
config: effectiveConfig,
child: const KellMobileApp(),
),
);
}
```
### 3. Access services in widgets
```dart
// Anywhere in the widget tree:
final services = KcAppScope.of<MobileAppServices>(context);
final config = KcAppScope.configOf<MobileAppServices>(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.

View File

@ -1,5 +1,17 @@
/// A Calculator. /// Core shared abstractions for Kell Creations applications.
class Calculator { ///
/// Returns [value] plus 1. /// This package provides the platform-agnostic composition pattern used by
int addOne(int value) => value + 1; /// 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';

View File

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

View File

@ -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<T>(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<WebAppServices>(context);
/// services.inventoryRepository; // strongly typed
/// ```
class KcAppScope<T extends KcAppServices> 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<T extends KcAppServices>(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<KcAppScope<T>>();
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<T extends KcAppServices>(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<KcAppScope<T>>();
assert(scope != null, 'No KcAppScope<$T> found in the widget tree');
return scope!.config;
}
@override
bool updateShouldNotify(KcAppScope<T> oldWidget) =>
services != oldWidget.services || config != oldWidget.config;
}

View File

@ -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<WebAppServices>(
/// createFake: () => WebAppServices.fake(),
/// createWordPress: (config) => WebAppServices.wordpress(
/// siteUrl: config.wcSiteUrl,
/// consumerKey: config.wcConsumerKey,
/// consumerSecret: config.wcConsumerSecret,
/// ),
/// );
/// ```
class KcServiceFactory<T extends KcAppServices> {
/// 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});
}

View File

@ -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<WebAppServices>(
/// 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<T extends KcAppServices>(
KcAppConfig config,
KcServiceFactory<T> 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);
}
}
}

View File

@ -1,12 +1,297 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:core/core.dart'; import 'package:core/core.dart';
void main() { void main() {
test('adds one to input values', () { group('KcAppConfig', () {
final calculator = Calculator(); test('creates config with required fields', () {
expect(calculator.addOne(2), 3); const config = KcAppConfig(
expect(calculator.addOne(-7), -6); environment: KcAppEnvironment.fake,
expect(calculator.addOne(0), 1); 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<KcAppServices>());
});
});
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<T> 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'});
}