Compare commits
2 Commits
6f10efc88d
...
0a0abc2c3d
| Author | SHA1 | Date |
|---|---|---|
|
|
0a0abc2c3d | |
|
|
9eafc68fec |
|
|
@ -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** |
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue