Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1

Merged
mtkell merged 12 commits from feat/inventory-first-slice into main 2026-04-04 19:46:31 +00:00
6 changed files with 226 additions and 8 deletions
Showing only changes of commit 129a66f0cf - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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