Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1
|
|
@ -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 '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<AppScope>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue