diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart new file mode 100644 index 0000000..340d33a --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_config.dart @@ -0,0 +1,85 @@ +/// Runtime configuration read from `--dart-define` values. +/// +/// The app reads the following compile-time constants: +/// +/// | Key | Description | Default | +/// |------------------------|----------------------------------------------|----------| +/// | `KC_ENV` | `fake` or `wordpress` | `fake` | +/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) | +/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | +/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) | +/// +/// Usage: +/// ```sh +/// flutter run -d chrome \ +/// --dart-define=KC_ENV=wordpress \ +/// --dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \ +/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ +/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx +/// ``` +class AppConfig { + /// The environment mode: `fake` or `wordpress`. + final AppEnvironment environment; + + /// WordPress / WooCommerce site URL (only used in [AppEnvironment.wordpress]). + final String wcSiteUrl; + + /// WooCommerce REST API consumer key. + final String wcConsumerKey; + + /// WooCommerce REST API consumer secret. + final String wcConsumerSecret; + + const AppConfig({ + required this.environment, + required this.wcSiteUrl, + required this.wcConsumerKey, + required this.wcConsumerSecret, + }); + + /// Reads configuration from compile-time `--dart-define` constants. + factory AppConfig.fromEnvironment() { + const envString = String.fromEnvironment('KC_ENV', defaultValue: 'fake'); + const siteUrl = String.fromEnvironment('KC_WC_SITE_URL'); + const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY'); + const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET'); + + final environment = AppEnvironment.fromString(envString); + + return AppConfig( + environment: environment, + wcSiteUrl: siteUrl, + wcConsumerKey: consumerKey, + wcConsumerSecret: consumerSecret, + ); + } + + /// Whether the WordPress configuration values are all present and non-empty. + bool get hasWordPressConfig => + wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty; + + /// A human-readable label for the current environment (e.g. for badges). + String get environmentLabel => environment.label; +} + +/// The supported runtime environments. +enum AppEnvironment { + /// In-memory fakes – no network calls. + fake('fake', 'FAKE'), + + /// Real WooCommerce backend. + wordpress('wordpress', 'WP'); + + final String key; + final String label; + + const AppEnvironment(this.key, this.label); + + /// Parses a string into an [AppEnvironment], defaulting to [fake]. + static AppEnvironment fromString(String value) { + for (final env in values) { + if (env.key == value.toLowerCase()) return env; + } + return fake; + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart index 7cb940c..577b756 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_scope.dart @@ -1,15 +1,18 @@ import 'package:flutter/widgets.dart'; +import 'app_config.dart'; import 'app_services.dart'; -/// An [InheritedWidget] that exposes [AppServices] to the widget tree. +/// An [InheritedWidget] that exposes [AppServices] and [AppConfig] to the +/// widget tree. /// /// Wrap the app (or a subtree) with [AppScope] and retrieve the services /// anywhere below via [AppScope.of(context)]. class AppScope extends InheritedWidget { final AppServices services; + final AppConfig config; - const AppScope({super.key, required this.services, required super.child}); + const AppScope({super.key, required this.services, required this.config, required super.child}); /// Returns the nearest [AppServices] from the widget tree. /// @@ -20,6 +23,16 @@ class AppScope extends InheritedWidget { return scope!.services; } + /// Returns the nearest [AppConfig] from the widget tree. + /// + /// Throws if no [AppScope] ancestor is found. + static AppConfig configOf(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'No AppScope found in the widget tree'); + return scope!.config; + } + @override - bool updateShouldNotify(AppScope oldWidget) => services != oldWidget.services; + bool updateShouldNotify(AppScope oldWidget) => + services != oldWidget.services || config != oldWidget.config; } diff --git a/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart b/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart new file mode 100644 index 0000000..8fc3dfa --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/bootstrap.dart @@ -0,0 +1,56 @@ +import 'package:flutter/foundation.dart'; + +import 'app_config.dart'; +import 'app_services.dart'; + +/// Bootstraps [AppServices] from the runtime [AppConfig]. +/// +/// In **fake** mode the in-memory fakes are used unconditionally. +/// +/// In **wordpress** mode the WooCommerce credentials are validated first. +/// If any credential is missing the app falls back to fake mode and logs a +/// warning so the developer knows what went wrong. +class Bootstrap { + const Bootstrap._(); + + /// Creates the appropriate [AppServices] for the given [config]. + /// + /// Returns a record containing the resolved services and the effective + /// config (which may differ from the input when WordPress credentials are + /// missing and a fallback to fake mode occurs). + static ({AppServices services, AppConfig config}) run(AppConfig config) { + switch (config.environment) { + case AppEnvironment.fake: + return (services: AppServices.fake(), config: config); + + case AppEnvironment.wordpress: + if (!config.hasWordPressConfig) { + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=wordpress but WooCommerce credentials are missing.\n' + ' Falling back to fake mode.\n' + ' Set KC_WC_SITE_URL, KC_WC_CONSUMER_KEY, and ' + 'KC_WC_CONSUMER_SECRET via --dart-define.', + ); + } + final fallbackConfig = AppConfig( + environment: AppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: AppServices.fake(), config: fallbackConfig); + } + + return ( + services: AppServices.wordpress( + siteUrl: config.wcSiteUrl, + consumerKey: config.wcConsumerKey, + consumerSecret: config.wcConsumerSecret, + ), + config: config, + ); + } + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/main.dart b/kell_creations_apps/apps/kell_web/lib/main.dart index d109acb..140f18f 100644 --- a/kell_creations_apps/apps/kell_web/lib/main.dart +++ b/kell_creations_apps/apps/kell_web/lib/main.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'app.dart'; +import 'composition/app_config.dart'; import 'composition/app_scope.dart'; -import 'composition/app_services.dart'; +import 'composition/bootstrap.dart'; void main() { - final services = AppServices.fake(); - runApp(AppScope(services: services, child: const KellWebApp())); + final config = AppConfig.fromEnvironment(); + final (:services, config: effectiveConfig) = Bootstrap.run(config); + + runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp())); } diff --git a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart index 5c51d76..5225cc0 100644 --- a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart +++ b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../composition/app_config.dart'; +import '../composition/app_scope.dart'; import '../routing/app_routes.dart'; class AppShell extends StatelessWidget { @@ -17,9 +19,16 @@ class AppShell extends StatelessWidget { @override Widget build(BuildContext context) { final index = _routeToIndex(selectedRoute); + final config = AppScope.configOf(context); return Scaffold( - appBar: AppBar(title: const Text('Kell Creations')), + appBar: AppBar( + title: const Text('Kell Creations'), + actions: [ + _EnvironmentBadge(environment: config.environment), + const SizedBox(width: 12), + ], + ), body: Row( children: [ NavigationRail( @@ -136,3 +145,41 @@ class AppShell extends StatelessWidget { } } } + +/// A small coloured chip displayed in the [AppBar] that shows the current +/// runtime environment (e.g. "FAKE" or "WP"). +class _EnvironmentBadge extends StatelessWidget { + final AppEnvironment environment; + + const _EnvironmentBadge({required this.environment}); + + @override + Widget build(BuildContext context) { + final Color backgroundColor; + final Color foregroundColor; + + switch (environment) { + case AppEnvironment.fake: + backgroundColor = Colors.orange.shade100; + foregroundColor = Colors.orange.shade900; + case AppEnvironment.wordpress: + backgroundColor = Colors.green.shade100; + foregroundColor = Colors.green.shade900; + } + + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(4)), + child: Text( + environment.label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/test/widget_test.dart b/kell_creations_apps/apps/kell_web/test/widget_test.dart index b8bc38d..cb373f8 100644 --- a/kell_creations_apps/apps/kell_web/test/widget_test.dart +++ b/kell_creations_apps/apps/kell_web/test/widget_test.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kell_web/app.dart'; +import 'package:kell_web/composition/app_config.dart'; import 'package:kell_web/composition/app_scope.dart'; import 'package:kell_web/composition/app_services.dart'; Widget _buildTestApp() { - return AppScope(services: AppServices.fake(), child: const KellWebApp()); + const config = AppConfig( + environment: AppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp()); } void main() { @@ -60,4 +67,11 @@ void main() { expect(find.text('Recent Activity'), findsOneWidget); }); + + testWidgets('environment badge shows FAKE in fake mode', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); + + expect(find.text('FAKE'), findsOneWidget); + }); }