feat(kell-web): add runtime environment bootstrap for wordpress mode
Validate Docs / validate-docs (push) Successful in 1m5s
Details
Validate Docs / validate-docs (push) Successful in 1m5s
Details
This commit is contained in:
parent
7ab526f083
commit
129a66f0cf
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
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] 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
|
/// Wrap the app (or a subtree) with [AppScope] and retrieve the services
|
||||||
/// anywhere below via [AppScope.of(context)].
|
/// anywhere below via [AppScope.of(context)].
|
||||||
class AppScope extends InheritedWidget {
|
class AppScope extends InheritedWidget {
|
||||||
final AppServices services;
|
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.
|
/// Returns the nearest [AppServices] from the widget tree.
|
||||||
///
|
///
|
||||||
|
|
@ -20,6 +23,16 @@ class AppScope extends InheritedWidget {
|
||||||
return scope!.services;
|
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<AppScope>();
|
||||||
|
assert(scope != null, 'No AppScope found in the widget tree');
|
||||||
|
return scope!.config;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(AppScope oldWidget) => services != oldWidget.services;
|
bool updateShouldNotify(AppScope oldWidget) =>
|
||||||
|
services != oldWidget.services || config != oldWidget.config;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'composition/app_config.dart';
|
||||||
import 'composition/app_scope.dart';
|
import 'composition/app_scope.dart';
|
||||||
import 'composition/app_services.dart';
|
import 'composition/bootstrap.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final services = AppServices.fake();
|
final config = AppConfig.fromEnvironment();
|
||||||
runApp(AppScope(services: services, child: const KellWebApp()));
|
final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
||||||
|
|
||||||
|
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../composition/app_config.dart';
|
||||||
|
import '../composition/app_scope.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
|
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
|
|
@ -17,9 +19,16 @@ class AppShell extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final index = _routeToIndex(selectedRoute);
|
final index = _routeToIndex(selectedRoute);
|
||||||
|
final config = AppScope.configOf(context);
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kell_web/app.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_scope.dart';
|
||||||
import 'package:kell_web/composition/app_services.dart';
|
import 'package:kell_web/composition/app_services.dart';
|
||||||
|
|
||||||
Widget _buildTestApp() {
|
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() {
|
void main() {
|
||||||
|
|
@ -60,4 +67,11 @@ void main() {
|
||||||
|
|
||||||
expect(find.text('Recent Activity'), findsOneWidget);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue