Compare commits
No commits in common. "d8f8fb6797efd98b716ddb9a720f4ae057c23b0b" and "59548cedbd9147110edbc407e159258533b991a1" have entirely different histories.
d8f8fb6797
...
59548cedbd
|
|
@ -1,18 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'routing/app_routes.dart';
|
|
||||||
|
|
||||||
class KellWebApp extends StatelessWidget {
|
|
||||||
const KellWebApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'Kell Creations',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: buildKcTheme(),
|
|
||||||
initialRoute: AppRoutes.dashboard,
|
|
||||||
onGenerateRoute: AppRoutes.onGenerateRoute,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/// 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,38 +0,0 @@
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
import 'app_config.dart';
|
|
||||||
import 'app_services.dart';
|
|
||||||
|
|
||||||
/// 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 this.config, required super.child});
|
|
||||||
|
|
||||||
/// Returns the nearest [AppServices] from the widget tree.
|
|
||||||
///
|
|
||||||
/// Throws if no [AppScope] ancestor is found.
|
|
||||||
static AppServices of(BuildContext context) {
|
|
||||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
|
||||||
assert(scope != null, 'No AppScope found in the widget tree');
|
|
||||||
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 || config != oldWidget.config;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
|
|
||||||
/// Holds the concrete service implementations used by the app.
|
|
||||||
///
|
|
||||||
/// The [AppServices.fake] factory wires up the in-memory fakes that live
|
|
||||||
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
|
||||||
/// a real WooCommerce-backed product repository while keeping other services
|
|
||||||
/// fake until their backends are ready.
|
|
||||||
class AppServices {
|
|
||||||
final InventoryRepository inventoryRepository;
|
|
||||||
final OrdersRepository ordersRepository;
|
|
||||||
final PolicyRepository policyRepository;
|
|
||||||
final ProductPublishingRepository productPublishingRepository;
|
|
||||||
|
|
||||||
const AppServices({
|
|
||||||
required this.inventoryRepository,
|
|
||||||
required this.ordersRepository,
|
|
||||||
required this.policyRepository,
|
|
||||||
required this.productPublishingRepository,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
|
||||||
factory AppServices.fake() {
|
|
||||||
return AppServices(
|
|
||||||
inventoryRepository: FakeInventoryRepository(),
|
|
||||||
ordersRepository: FakeOrdersRepository(),
|
|
||||||
policyRepository: FakePolicyRepository(),
|
|
||||||
productPublishingRepository: FakeProductPublishingRepository(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an [AppServices] with a real WooCommerce-backed product
|
|
||||||
/// repository. Other repositories remain fake until their backends are
|
|
||||||
/// ready.
|
|
||||||
///
|
|
||||||
/// [siteUrl] – the WordPress site URL (e.g. `https://store.kellcreations.com`).
|
|
||||||
/// [consumerKey] – WooCommerce REST API consumer key.
|
|
||||||
/// [consumerSecret] – WooCommerce REST API consumer secret.
|
|
||||||
factory AppServices.wordpress({
|
|
||||||
required String siteUrl,
|
|
||||||
required String consumerKey,
|
|
||||||
required String consumerSecret,
|
|
||||||
}) {
|
|
||||||
final apiClient = WooCommerceApiClient(
|
|
||||||
siteUrl: siteUrl,
|
|
||||||
consumerKey: consumerKey,
|
|
||||||
consumerSecret: consumerSecret,
|
|
||||||
);
|
|
||||||
|
|
||||||
return AppServices(
|
|
||||||
inventoryRepository: FakeInventoryRepository(),
|
|
||||||
ordersRepository: FakeOrdersRepository(),
|
|
||||||
policyRepository: FakePolicyRepository(),
|
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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,34 +0,0 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../domain/dashboard_summary.dart';
|
|
||||||
import 'get_dashboard_summary.dart';
|
|
||||||
|
|
||||||
/// Controller that manages the dashboard summary state.
|
|
||||||
///
|
|
||||||
/// Follows the same [ChangeNotifier] pattern used by other feature
|
|
||||||
/// controllers (e.g. `OrdersController`).
|
|
||||||
class DashboardController extends ChangeNotifier {
|
|
||||||
final GetDashboardSummary _getDashboardSummary;
|
|
||||||
|
|
||||||
DashboardController(this._getDashboardSummary);
|
|
||||||
|
|
||||||
bool isLoading = false;
|
|
||||||
DashboardSummary summary = DashboardSummary.empty;
|
|
||||||
Object? error;
|
|
||||||
|
|
||||||
/// Loads the aggregated dashboard summary from all repositories.
|
|
||||||
Future<void> load() async {
|
|
||||||
isLoading = true;
|
|
||||||
error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
summary = await _getDashboardSummary();
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
|
|
||||||
import '../domain/dashboard_summary.dart';
|
|
||||||
|
|
||||||
/// Use case: fetches data from all three repositories and returns an
|
|
||||||
/// aggregated [DashboardSummary].
|
|
||||||
///
|
|
||||||
/// This lives in the app layer (not in a feature package) because it
|
|
||||||
/// crosses feature boundaries.
|
|
||||||
class GetDashboardSummary {
|
|
||||||
final InventoryRepository inventoryRepository;
|
|
||||||
final ProductPublishingRepository productPublishingRepository;
|
|
||||||
final OrdersRepository ordersRepository;
|
|
||||||
|
|
||||||
GetDashboardSummary({
|
|
||||||
required this.inventoryRepository,
|
|
||||||
required this.productPublishingRepository,
|
|
||||||
required this.ordersRepository,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<DashboardSummary> call() async {
|
|
||||||
final results = await Future.wait([
|
|
||||||
inventoryRepository.getInventoryItems(),
|
|
||||||
productPublishingRepository.getProductDrafts(),
|
|
||||||
ordersRepository.getOrders(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return DashboardSummary.fromData(
|
|
||||||
inventoryItems: results[0] as List<InventoryItem>,
|
|
||||||
productDrafts: results[1] as List<ProductDraft>,
|
|
||||||
orders: results[2] as List<Order>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
|
|
||||||
/// Aggregated summary data displayed on the dashboard.
|
|
||||||
///
|
|
||||||
/// This is an app-level value object that composes data from multiple
|
|
||||||
/// feature-package repositories without leaking domain logic back into
|
|
||||||
/// those packages.
|
|
||||||
class DashboardSummary {
|
|
||||||
/// Total number of inventory items.
|
|
||||||
final int totalProducts;
|
|
||||||
|
|
||||||
/// Items with [InventoryStatus.inStock].
|
|
||||||
final int inStock;
|
|
||||||
|
|
||||||
/// Items with [InventoryStatus.lowStock].
|
|
||||||
final int lowStock;
|
|
||||||
|
|
||||||
/// Items with [InventoryStatus.outOfStock].
|
|
||||||
final int outOfStock;
|
|
||||||
|
|
||||||
/// Product drafts with [PublishStatus.draft].
|
|
||||||
final int draftProducts;
|
|
||||||
|
|
||||||
/// Total number of orders.
|
|
||||||
final int totalOrders;
|
|
||||||
|
|
||||||
/// Orders with [OrderStatus.pending].
|
|
||||||
final int pendingOrders;
|
|
||||||
|
|
||||||
/// Orders with [OrderStatus.processing] or [OrderStatus.shipped].
|
|
||||||
final int activeOrders;
|
|
||||||
|
|
||||||
/// Revenue from delivered orders.
|
|
||||||
final double deliveredRevenue;
|
|
||||||
|
|
||||||
const DashboardSummary({
|
|
||||||
required this.totalProducts,
|
|
||||||
required this.inStock,
|
|
||||||
required this.lowStock,
|
|
||||||
required this.outOfStock,
|
|
||||||
required this.draftProducts,
|
|
||||||
required this.totalOrders,
|
|
||||||
required this.pendingOrders,
|
|
||||||
required this.activeOrders,
|
|
||||||
required this.deliveredRevenue,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
other is DashboardSummary &&
|
|
||||||
totalProducts == other.totalProducts &&
|
|
||||||
inStock == other.inStock &&
|
|
||||||
lowStock == other.lowStock &&
|
|
||||||
outOfStock == other.outOfStock &&
|
|
||||||
draftProducts == other.draftProducts &&
|
|
||||||
totalOrders == other.totalOrders &&
|
|
||||||
pendingOrders == other.pendingOrders &&
|
|
||||||
activeOrders == other.activeOrders &&
|
|
||||||
deliveredRevenue == other.deliveredRevenue;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(
|
|
||||||
totalProducts,
|
|
||||||
inStock,
|
|
||||||
lowStock,
|
|
||||||
outOfStock,
|
|
||||||
draftProducts,
|
|
||||||
totalOrders,
|
|
||||||
pendingOrders,
|
|
||||||
activeOrders,
|
|
||||||
deliveredRevenue,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// An empty summary used as the initial / default state.
|
|
||||||
static const empty = DashboardSummary(
|
|
||||||
totalProducts: 0,
|
|
||||||
inStock: 0,
|
|
||||||
lowStock: 0,
|
|
||||||
outOfStock: 0,
|
|
||||||
draftProducts: 0,
|
|
||||||
totalOrders: 0,
|
|
||||||
pendingOrders: 0,
|
|
||||||
activeOrders: 0,
|
|
||||||
deliveredRevenue: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Computes a [DashboardSummary] from raw repository data.
|
|
||||||
factory DashboardSummary.fromData({
|
|
||||||
required List<InventoryItem> inventoryItems,
|
|
||||||
required List<ProductDraft> productDrafts,
|
|
||||||
required List<Order> orders,
|
|
||||||
}) {
|
|
||||||
// Inventory counts
|
|
||||||
final totalProducts = inventoryItems.length;
|
|
||||||
var inStock = 0;
|
|
||||||
var lowStock = 0;
|
|
||||||
var outOfStock = 0;
|
|
||||||
for (final item in inventoryItems) {
|
|
||||||
switch (item.status) {
|
|
||||||
case InventoryStatus.inStock:
|
|
||||||
inStock++;
|
|
||||||
case InventoryStatus.lowStock:
|
|
||||||
lowStock++;
|
|
||||||
case InventoryStatus.outOfStock:
|
|
||||||
outOfStock++;
|
|
||||||
case InventoryStatus.draft:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draft product count
|
|
||||||
final draftProducts = productDrafts.where((d) => d.status == PublishStatus.draft).length;
|
|
||||||
|
|
||||||
// Order counts
|
|
||||||
final totalOrders = orders.length;
|
|
||||||
var pendingOrders = 0;
|
|
||||||
var activeOrders = 0;
|
|
||||||
var deliveredRevenue = 0.0;
|
|
||||||
for (final order in orders) {
|
|
||||||
switch (order.status) {
|
|
||||||
case OrderStatus.pending:
|
|
||||||
pendingOrders++;
|
|
||||||
case OrderStatus.processing:
|
|
||||||
case OrderStatus.shipped:
|
|
||||||
activeOrders++;
|
|
||||||
case OrderStatus.delivered:
|
|
||||||
deliveredRevenue += order.total;
|
|
||||||
case OrderStatus.cancelled:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DashboardSummary(
|
|
||||||
totalProducts: totalProducts,
|
|
||||||
inStock: inStock,
|
|
||||||
lowStock: lowStock,
|
|
||||||
outOfStock: outOfStock,
|
|
||||||
draftProducts: draftProducts,
|
|
||||||
totalOrders: totalOrders,
|
|
||||||
pendingOrders: pendingOrders,
|
|
||||||
activeOrders: activeOrders,
|
|
||||||
deliveredRevenue: deliveredRevenue,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,122 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
|
||||||
import 'composition/app_config.dart';
|
|
||||||
import 'composition/app_scope.dart';
|
|
||||||
import 'composition/bootstrap.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final config = AppConfig.fromEnvironment();
|
runApp(const MyApp());
|
||||||
final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
}
|
||||||
|
|
||||||
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
// This widget is the root of your application.
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Flutter Demo',
|
||||||
|
theme: ThemeData(
|
||||||
|
// This is the theme of your application.
|
||||||
|
//
|
||||||
|
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||||
|
// the application has a purple toolbar. Then, without quitting the app,
|
||||||
|
// try changing the seedColor in the colorScheme below to Colors.green
|
||||||
|
// and then invoke "hot reload" (save your changes or press the "hot
|
||||||
|
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||||
|
// the command line to start the app).
|
||||||
|
//
|
||||||
|
// Notice that the counter didn't reset back to zero; the application
|
||||||
|
// state is not lost during the reload. To reset the state, use hot
|
||||||
|
// restart instead.
|
||||||
|
//
|
||||||
|
// This works for code too, not just values: Most code changes can be
|
||||||
|
// tested with just a hot reload.
|
||||||
|
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||||
|
),
|
||||||
|
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyHomePage extends StatefulWidget {
|
||||||
|
const MyHomePage({super.key, required this.title});
|
||||||
|
|
||||||
|
// This widget is the home page of your application. It is stateful, meaning
|
||||||
|
// that it has a State object (defined below) that contains fields that affect
|
||||||
|
// how it looks.
|
||||||
|
|
||||||
|
// This class is the configuration for the state. It holds the values (in this
|
||||||
|
// case the title) provided by the parent (in this case the App widget) and
|
||||||
|
// used by the build method of the State. Fields in a Widget subclass are
|
||||||
|
// always marked "final".
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyHomePage> createState() => _MyHomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
|
int _counter = 0;
|
||||||
|
|
||||||
|
void _incrementCounter() {
|
||||||
|
setState(() {
|
||||||
|
// This call to setState tells the Flutter framework that something has
|
||||||
|
// changed in this State, which causes it to rerun the build method below
|
||||||
|
// so that the display can reflect the updated values. If we changed
|
||||||
|
// _counter without calling setState(), then the build method would not be
|
||||||
|
// called again, and so nothing would appear to happen.
|
||||||
|
_counter++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// This method is rerun every time setState is called, for instance as done
|
||||||
|
// by the _incrementCounter method above.
|
||||||
|
//
|
||||||
|
// The Flutter framework has been optimized to make rerunning build methods
|
||||||
|
// fast, so that you can just rebuild anything that needs updating rather
|
||||||
|
// than having to individually change instances of widgets.
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
// TRY THIS: Try changing the color here to a specific color (to
|
||||||
|
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||||
|
// change color while the other colors stay the same.
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
|
// Here we take the value from the MyHomePage object that was created by
|
||||||
|
// the App.build method, and use it to set our appbar title.
|
||||||
|
title: Text(widget.title),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
// Center is a layout widget. It takes a single child and positions it
|
||||||
|
// in the middle of the parent.
|
||||||
|
child: Column(
|
||||||
|
// Column is also a layout widget. It takes a list of children and
|
||||||
|
// arranges them vertically. By default, it sizes itself to fit its
|
||||||
|
// children horizontally, and tries to be as tall as its parent.
|
||||||
|
//
|
||||||
|
// Column has various properties to control how it sizes itself and
|
||||||
|
// how it positions its children. Here we use mainAxisAlignment to
|
||||||
|
// center the children vertically; the main axis here is the vertical
|
||||||
|
// axis because Columns are vertical (the cross axis would be
|
||||||
|
// horizontal).
|
||||||
|
//
|
||||||
|
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||||
|
// action in the IDE, or press "p" in the console), to see the
|
||||||
|
// wireframe for each widget.
|
||||||
|
mainAxisAlignment: .center,
|
||||||
|
children: [
|
||||||
|
const Text('You have pushed the button this many times:'),
|
||||||
|
Text(
|
||||||
|
'$_counter',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _incrementCounter,
|
||||||
|
tooltip: 'Increment',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
import '../routing/app_routes.dart';
|
|
||||||
import 'navigation_target.dart';
|
|
||||||
|
|
||||||
/// App-level navigation helpers for cross-feature handoffs.
|
|
||||||
///
|
|
||||||
/// All navigation logic lives here in the app layer so that feature packages
|
|
||||||
/// never depend on each other's routes. Feature pages receive plain
|
|
||||||
/// `void Function(String)` callbacks that they invoke with a SKU or ID;
|
|
||||||
/// the wiring in [AppRoutes.onGenerateRoute] closes over these helpers.
|
|
||||||
abstract final class AppNavigation {
|
|
||||||
// ── Generic ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Navigate to a [NavigationTarget], replacing the current route.
|
|
||||||
static void navigateTo(BuildContext context, NavigationTarget target) {
|
|
||||||
Navigator.of(context).pushReplacementNamed(target.route, arguments: target.arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dashboard → feature handoffs ──────────────────────────────────────
|
|
||||||
|
|
||||||
/// Dashboard KPI "Total Products" / "In Stock" → Inventory page.
|
|
||||||
static void dashboardToInventory(BuildContext context, {String? filter, String? query}) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter, 'query': ?query}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dashboard KPI "Draft" → Products page.
|
|
||||||
static void dashboardToProducts(BuildContext context, {String? filter, String? query}) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter, 'query': ?query}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dashboard KPI "Total Orders" / "Pending" / "Active" → Orders page.
|
|
||||||
static void dashboardToOrders(BuildContext context, {String? filter, String? query}) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter, 'query': ?query}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dashboard KPI "Revenue" → Finance page.
|
|
||||||
static void dashboardToFinance(BuildContext context) {
|
|
||||||
navigateTo(context, NavigationTarget(route: AppRoutes.finance));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Policy detail → operational page handoffs ─────────────────────────
|
|
||||||
|
|
||||||
/// Policy detail action → related operational page based on category.
|
|
||||||
static void policyToRelatedPage(BuildContext context, {required String category}) {
|
|
||||||
final route = _categoryToRoute(category);
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: route, arguments: {'fromPolicy': 'true', 'category': category}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Orders → Products / Inventory ─────────────────────────────────────
|
|
||||||
|
|
||||||
/// Order line-item SKU → Products page, pre-selecting that SKU.
|
|
||||||
static void orderToProduct(BuildContext context, {required String sku}) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.products, arguments: {'selectedSku': sku}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Order line-item SKU → Inventory page, pre-selecting that SKU.
|
|
||||||
static void orderToInventory(BuildContext context, {required String sku}) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.inventory, arguments: {'selectedSku': sku}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Products → Policy ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Product draft → Policy page filtered to "Product Compliance".
|
|
||||||
static void productToPolicy(BuildContext context) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.policy, arguments: {'category': 'Product Compliance'}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inventory → Products ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Inventory item → Products page, pre-selecting by SKU.
|
|
||||||
static void inventoryToProduct(BuildContext context, {required String sku}) {
|
|
||||||
navigateTo(
|
|
||||||
context,
|
|
||||||
NavigationTarget(route: AppRoutes.products, arguments: {'selectedSku': sku}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
static String _categoryToRoute(String category) {
|
|
||||||
switch (category) {
|
|
||||||
case 'Product Compliance':
|
|
||||||
return AppRoutes.products;
|
|
||||||
case 'Inventory Governance':
|
|
||||||
return AppRoutes.inventory;
|
|
||||||
case 'Order Operations':
|
|
||||||
return AppRoutes.orders;
|
|
||||||
case 'Finance & Tax':
|
|
||||||
return AppRoutes.finance;
|
|
||||||
case 'Customer Policy':
|
|
||||||
return AppRoutes.orders;
|
|
||||||
default:
|
|
||||||
return AppRoutes.dashboard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/// A lightweight value object describing a cross-feature navigation handoff.
|
|
||||||
///
|
|
||||||
/// Carries the destination route and optional arguments (e.g. a selected-item
|
|
||||||
/// ID or a preset filter) so the target page can open in the right state.
|
|
||||||
///
|
|
||||||
/// This lives in the app layer (`kell_web`) and is never imported by feature
|
|
||||||
/// packages, keeping package boundaries strict.
|
|
||||||
class NavigationTarget {
|
|
||||||
/// The route path to navigate to (one of [AppRoutes] constants).
|
|
||||||
final String route;
|
|
||||||
|
|
||||||
/// Optional key-value arguments for the destination page.
|
|
||||||
///
|
|
||||||
/// Common keys:
|
|
||||||
/// - `selectedSku` – pre-select an item by SKU on the target page
|
|
||||||
/// - `selectedId` – pre-select an item by ID on the target page
|
|
||||||
/// - `filter` – apply a preset filter (e.g. `'lowStock'`)
|
|
||||||
/// - `category` – filter by policy category
|
|
||||||
final Map<String, String> arguments;
|
|
||||||
|
|
||||||
const NavigationTarget({required this.route, this.arguments = const {}});
|
|
||||||
|
|
||||||
/// Convenience: the selected SKU, if any.
|
|
||||||
String? get selectedSku => arguments['selectedSku'];
|
|
||||||
|
|
||||||
/// Convenience: the selected ID, if any.
|
|
||||||
String? get selectedId => arguments['selectedId'];
|
|
||||||
|
|
||||||
/// Convenience: a preset filter, if any.
|
|
||||||
String? get filter => arguments['filter'];
|
|
||||||
|
|
||||||
/// Convenience: a category filter, if any.
|
|
||||||
String? get category => arguments['category'];
|
|
||||||
|
|
||||||
/// Convenience: a search query, if any.
|
|
||||||
String? get query => arguments['query'];
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
other is NavigationTarget && route == other.route && _mapsEqual(arguments, other.arguments);
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode {
|
|
||||||
// Build a stable hash from sorted keys to ensure consistency.
|
|
||||||
var argsHash = 0;
|
|
||||||
final sortedKeys = arguments.keys.toList()..sort();
|
|
||||||
for (final key in sortedKeys) {
|
|
||||||
argsHash = argsHash ^ Object.hash(key, arguments[key]);
|
|
||||||
}
|
|
||||||
return Object.hash(route, argsHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'NavigationTarget(route: $route, arguments: $arguments)';
|
|
||||||
|
|
||||||
static bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
|
|
||||||
if (a.length != b.length) return false;
|
|
||||||
for (final key in a.keys) {
|
|
||||||
if (a[key] != b[key]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../dashboard/application/dashboard_controller.dart';
|
|
||||||
import '../dashboard/domain/dashboard_summary.dart';
|
|
||||||
import '../navigation/app_navigation.dart';
|
|
||||||
import '../routing/app_routes.dart';
|
|
||||||
import '../shell/widgets/empty_state_panel.dart';
|
|
||||||
import '../shell/widgets/section_header.dart';
|
|
||||||
import '../shell/widgets/summary_card.dart';
|
|
||||||
|
|
||||||
/// The main dashboard page showing aggregated summary data.
|
|
||||||
///
|
|
||||||
/// Displays KPI cards (total products, in-stock, low-stock, draft, orders,
|
|
||||||
/// pending, active, revenue) and a quick-actions section. Data is loaded
|
|
||||||
/// from the [DashboardController] which aggregates across repositories.
|
|
||||||
class DashboardPage extends StatefulWidget {
|
|
||||||
final DashboardController controller;
|
|
||||||
|
|
||||||
const DashboardPage({super.key, required this.controller});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DashboardPage> createState() => _DashboardPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DashboardPageState extends State<DashboardPage> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
widget.controller.addListener(_onControllerChanged);
|
|
||||||
// Kick off the initial load.
|
|
||||||
widget.controller.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(DashboardPage oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.controller != widget.controller) {
|
|
||||||
oldWidget.controller.removeListener(_onControllerChanged);
|
|
||||||
widget.controller.addListener(_onControllerChanged);
|
|
||||||
widget.controller.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
widget.controller.removeListener(_onControllerChanged);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onControllerChanged() => setState(() {});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final controller = widget.controller;
|
|
||||||
|
|
||||||
if (controller.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller.error != null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Failed to load dashboard data.', style: Theme.of(context).textTheme.bodyLarge),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final summary = controller.summary;
|
|
||||||
|
|
||||||
return ListView(
|
|
||||||
children: [
|
|
||||||
const SectionHeader(title: 'Overview'),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
_buildSummaryGrid(context, summary),
|
|
||||||
const SizedBox(height: KcSpacing.xl),
|
|
||||||
SectionHeader(
|
|
||||||
title: 'Quick Actions',
|
|
||||||
action: TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory),
|
|
||||||
child: const Text('Go to Inventory'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
_buildQuickActions(context),
|
|
||||||
const SizedBox(height: KcSpacing.xl),
|
|
||||||
const SectionHeader(title: 'Recent Activity'),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
const EmptyStatePanel(
|
|
||||||
icon: Icons.history,
|
|
||||||
message:
|
|
||||||
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Summary cards grid ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth;
|
|
||||||
|
|
||||||
int crossAxisCount = 2;
|
|
||||||
if (width >= 900) {
|
|
||||||
crossAxisCount = 4;
|
|
||||||
} else if (width >= 600) {
|
|
||||||
crossAxisCount = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
final cards = [
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.inventory_2,
|
|
||||||
iconColor: KcColors.denimBlue,
|
|
||||||
label: 'Total Products',
|
|
||||||
value: '${summary.totalProducts}',
|
|
||||||
onTap: () => AppNavigation.dashboardToInventory(context),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.check_circle_outline,
|
|
||||||
iconColor: KcColors.success,
|
|
||||||
label: 'In Stock',
|
|
||||||
value: '${summary.inStock}',
|
|
||||||
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.warning_amber_rounded,
|
|
||||||
iconColor: KcColors.warning,
|
|
||||||
label: 'Low Stock',
|
|
||||||
value: '${summary.lowStock}',
|
|
||||||
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.edit_note,
|
|
||||||
iconColor: KcColors.neutral,
|
|
||||||
label: 'Draft',
|
|
||||||
value: '${summary.draftProducts}',
|
|
||||||
onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.receipt_long,
|
|
||||||
iconColor: KcColors.denimBlue,
|
|
||||||
label: 'Total Orders',
|
|
||||||
value: '${summary.totalOrders}',
|
|
||||||
onTap: () => AppNavigation.dashboardToOrders(context),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.hourglass_empty,
|
|
||||||
iconColor: KcColors.warning,
|
|
||||||
label: 'Pending Orders',
|
|
||||||
value: '${summary.pendingOrders}',
|
|
||||||
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.local_shipping_outlined,
|
|
||||||
iconColor: KcColors.success,
|
|
||||||
label: 'Active Orders',
|
|
||||||
value: '${summary.activeOrders}',
|
|
||||||
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'),
|
|
||||||
),
|
|
||||||
SummaryCard(
|
|
||||||
icon: Icons.attach_money,
|
|
||||||
iconColor: KcColors.success,
|
|
||||||
label: 'Revenue',
|
|
||||||
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
|
|
||||||
onTap: () => AppNavigation.dashboardToFinance(context),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return GridView.count(
|
|
||||||
crossAxisCount: crossAxisCount,
|
|
||||||
crossAxisSpacing: KcSpacing.md,
|
|
||||||
mainAxisSpacing: KcSpacing.md,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
childAspectRatio: 1.8,
|
|
||||||
children: cards,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quick actions ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildQuickActions(BuildContext context) {
|
|
||||||
return KcCard(
|
|
||||||
child: Wrap(
|
|
||||||
spacing: KcSpacing.sm,
|
|
||||||
runSpacing: KcSpacing.sm,
|
|
||||||
children: [
|
|
||||||
_QuickActionChip(
|
|
||||||
icon: Icons.add,
|
|
||||||
label: 'New Product',
|
|
||||||
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.products),
|
|
||||||
),
|
|
||||||
_QuickActionChip(
|
|
||||||
icon: Icons.inventory_2_outlined,
|
|
||||||
label: 'View Inventory',
|
|
||||||
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory),
|
|
||||||
),
|
|
||||||
_QuickActionChip(
|
|
||||||
icon: Icons.receipt_long_outlined,
|
|
||||||
label: 'View Orders',
|
|
||||||
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.orders),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helper widget ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _QuickActionChip extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
const _QuickActionChip({required this.icon, required this.label, required this.onPressed});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ActionChip(avatar: Icon(icon, size: 18), label: Text(label), onPressed: onPressed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class DashboardPlaceholderPage extends StatelessWidget {
|
|
||||||
const DashboardPlaceholderPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Center(child: Text('Dashboard page coming soon'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class FinancePlaceholderPage extends StatelessWidget {
|
|
||||||
const FinancePlaceholderPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Center(child: Text('Finance page coming soon'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class IntegrationsPlaceholderPage extends StatelessWidget {
|
|
||||||
const IntegrationsPlaceholderPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Center(child: Text('Integrations page coming soon'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class OrdersPlaceholderPage extends StatelessWidget {
|
|
||||||
const OrdersPlaceholderPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Center(child: Text('Orders page coming soon'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class PolicyPlaceholderPage extends StatelessWidget {
|
|
||||||
const PolicyPlaceholderPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Center(child: Text('Policy page coming soon'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class ProductsPlaceholderPage extends StatelessWidget {
|
|
||||||
const ProductsPlaceholderPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Center(child: Text('Products page coming soon'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../composition/app_scope.dart';
|
|
||||||
import '../dashboard/application/dashboard_controller.dart';
|
|
||||||
import '../dashboard/application/get_dashboard_summary.dart';
|
|
||||||
import '../navigation/app_navigation.dart';
|
|
||||||
import '../pages/dashboard_page.dart';
|
|
||||||
import '../pages/finance_placeholder_page.dart';
|
|
||||||
import '../pages/integrations_placeholder_page.dart';
|
|
||||||
import '../shell/app_shell.dart';
|
|
||||||
|
|
||||||
abstract final class AppRoutes {
|
|
||||||
static const String dashboard = '/';
|
|
||||||
static const String inventory = '/inventory';
|
|
||||||
static const String products = '/products';
|
|
||||||
static const String orders = '/orders';
|
|
||||||
static const String finance = '/finance';
|
|
||||||
static const String policy = '/policy';
|
|
||||||
static const String integrations = '/integrations';
|
|
||||||
|
|
||||||
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
|
|
||||||
// Extract navigation arguments passed via NavigationTarget.
|
|
||||||
final args = _extractArgs(settings);
|
|
||||||
|
|
||||||
switch (settings.name) {
|
|
||||||
case dashboard:
|
|
||||||
return _buildRoute(settings, (context) {
|
|
||||||
final services = AppScope.of(context);
|
|
||||||
final controller = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: services.inventoryRepository,
|
|
||||||
productPublishingRepository: services.productPublishingRepository,
|
|
||||||
ordersRepository: services.ordersRepository,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return AppShell(
|
|
||||||
selectedRoute: dashboard,
|
|
||||||
title: 'Dashboard',
|
|
||||||
child: DashboardPage(controller: controller),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
case inventory:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => AppShell(
|
|
||||||
selectedRoute: inventory,
|
|
||||||
title: 'Inventory',
|
|
||||||
child: InventoryPage(
|
|
||||||
repository: AppScope.of(context).inventoryRepository,
|
|
||||||
onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku),
|
|
||||||
initialFilter: args['filter'],
|
|
||||||
initialQuery: args['query'],
|
|
||||||
initialSelectedSku: args['selectedSku'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case products:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => AppShell(
|
|
||||||
selectedRoute: products,
|
|
||||||
title: 'Products',
|
|
||||||
child: ProductPublishingPage(
|
|
||||||
repository: AppScope.of(context).productPublishingRepository,
|
|
||||||
onViewPolicy: () => AppNavigation.productToPolicy(context),
|
|
||||||
initialFilter: args['filter'],
|
|
||||||
initialQuery: args['query'],
|
|
||||||
initialSelectedSku: args['selectedSku'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case orders:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => AppShell(
|
|
||||||
selectedRoute: orders,
|
|
||||||
title: 'Orders',
|
|
||||||
child: OrdersPage(
|
|
||||||
repository: AppScope.of(context).ordersRepository,
|
|
||||||
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
|
|
||||||
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
|
|
||||||
initialFilter: args['filter'],
|
|
||||||
initialQuery: args['query'],
|
|
||||||
initialSelectedId: args['selectedId'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case finance:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => const AppShell(
|
|
||||||
selectedRoute: finance,
|
|
||||||
title: 'Finance',
|
|
||||||
child: FinancePlaceholderPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case policy:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => AppShell(
|
|
||||||
selectedRoute: policy,
|
|
||||||
title: 'Policy',
|
|
||||||
child: PolicyPage(
|
|
||||||
repository: AppScope.of(context).policyRepository,
|
|
||||||
onViewRelatedPage: (category) =>
|
|
||||||
AppNavigation.policyToRelatedPage(context, category: category),
|
|
||||||
initialCategory: args['category'],
|
|
||||||
initialQuery: args['query'],
|
|
||||||
initialSelectedId: args['selectedId'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case integrations:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => const AppShell(
|
|
||||||
selectedRoute: integrations,
|
|
||||||
title: 'Integrations',
|
|
||||||
child: IntegrationsPlaceholderPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return _buildRoute(
|
|
||||||
settings,
|
|
||||||
(context) => AppShell(
|
|
||||||
selectedRoute: inventory,
|
|
||||||
title: 'Inventory',
|
|
||||||
child: InventoryPage(
|
|
||||||
repository: AppScope.of(context).inventoryRepository,
|
|
||||||
onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> _buildRoute(
|
|
||||||
RouteSettings settings,
|
|
||||||
Widget Function(BuildContext context) pageBuilder,
|
|
||||||
) {
|
|
||||||
return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Safely extracts the `Map<String, String>` arguments from [RouteSettings].
|
|
||||||
///
|
|
||||||
/// Returns an empty map when no arguments are present or the type doesn't
|
|
||||||
/// match, so callers can always index safely.
|
|
||||||
static Map<String, String> _extractArgs(RouteSettings settings) {
|
|
||||||
final raw = settings.arguments;
|
|
||||||
if (raw is Map<String, String>) return raw;
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../composition/app_config.dart';
|
|
||||||
import '../composition/app_scope.dart';
|
|
||||||
import '../routing/app_routes.dart';
|
|
||||||
|
|
||||||
class AppShell extends StatelessWidget {
|
|
||||||
final String selectedRoute;
|
|
||||||
final String title;
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
const AppShell({
|
|
||||||
super.key,
|
|
||||||
required this.selectedRoute,
|
|
||||||
required this.title,
|
|
||||||
required this.child,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final index = _routeToIndex(selectedRoute);
|
|
||||||
final config = AppScope.configOf(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Kell Creations'),
|
|
||||||
actions: [
|
|
||||||
_EnvironmentBadge(environment: config.environment),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Row(
|
|
||||||
children: [
|
|
||||||
NavigationRail(
|
|
||||||
selectedIndex: index,
|
|
||||||
onDestinationSelected: (selectedIndex) {
|
|
||||||
final route = _indexToRoute(selectedIndex);
|
|
||||||
if (route != selectedRoute) {
|
|
||||||
Navigator.of(context).pushReplacementNamed(route);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labelType: NavigationRailLabelType.all,
|
|
||||||
minWidth: 88,
|
|
||||||
minExtendedWidth: 200,
|
|
||||||
leading: const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Icon(Icons.storefront_outlined, size: 32),
|
|
||||||
),
|
|
||||||
destinations: const [
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.dashboard_outlined),
|
|
||||||
selectedIcon: Icon(Icons.dashboard),
|
|
||||||
label: Text('Dashboard'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.inventory_2_outlined),
|
|
||||||
selectedIcon: Icon(Icons.inventory_2),
|
|
||||||
label: Text('Inventory'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.sell_outlined),
|
|
||||||
selectedIcon: Icon(Icons.sell),
|
|
||||||
label: Text('Products'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.receipt_long_outlined),
|
|
||||||
selectedIcon: Icon(Icons.receipt_long),
|
|
||||||
label: Text('Orders'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.attach_money_outlined),
|
|
||||||
selectedIcon: Icon(Icons.attach_money),
|
|
||||||
label: Text('Finance'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.policy_outlined),
|
|
||||||
selectedIcon: Icon(Icons.policy),
|
|
||||||
label: Text('Policy'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.hub_outlined),
|
|
||||||
selectedIcon: Icon(Icons.hub),
|
|
||||||
label: Text('Integrations'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const VerticalDivider(width: 1),
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: Theme.of(context).textTheme.headlineMedium),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Expanded(child: child),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _routeToIndex(String route) {
|
|
||||||
switch (route) {
|
|
||||||
case AppRoutes.dashboard:
|
|
||||||
return 0;
|
|
||||||
case AppRoutes.inventory:
|
|
||||||
return 1;
|
|
||||||
case AppRoutes.products:
|
|
||||||
return 2;
|
|
||||||
case AppRoutes.orders:
|
|
||||||
return 3;
|
|
||||||
case AppRoutes.finance:
|
|
||||||
return 4;
|
|
||||||
case AppRoutes.policy:
|
|
||||||
return 5;
|
|
||||||
case AppRoutes.integrations:
|
|
||||||
return 6;
|
|
||||||
default:
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _indexToRoute(int index) {
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
return AppRoutes.dashboard;
|
|
||||||
case 1:
|
|
||||||
return AppRoutes.inventory;
|
|
||||||
case 2:
|
|
||||||
return AppRoutes.products;
|
|
||||||
case 3:
|
|
||||||
return AppRoutes.orders;
|
|
||||||
case 4:
|
|
||||||
return AppRoutes.finance;
|
|
||||||
case 5:
|
|
||||||
return AppRoutes.policy;
|
|
||||||
case 6:
|
|
||||||
return AppRoutes.integrations;
|
|
||||||
default:
|
|
||||||
return AppRoutes.inventory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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,38 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A reusable empty-state panel shown when a section has no data yet.
|
|
||||||
///
|
|
||||||
/// Displays an [icon], a [message], and an optional [action] widget
|
|
||||||
/// (e.g. a button to create the first item).
|
|
||||||
class EmptyStatePanel extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String message;
|
|
||||||
final Widget? action;
|
|
||||||
|
|
||||||
const EmptyStatePanel({super.key, required this.icon, required this.message, this.action});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return KcCard(
|
|
||||||
child: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xl),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 48, color: KcColors.neutral),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.neutral),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (action != null) ...[const SizedBox(height: KcSpacing.md), action!],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A reusable section header used across app pages.
|
|
||||||
///
|
|
||||||
/// Displays a [title] with an optional trailing [action] widget
|
|
||||||
/// (e.g. a "View all" button).
|
|
||||||
class SectionHeader extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final Widget? action;
|
|
||||||
|
|
||||||
const SectionHeader({super.key, required this.title, this.action});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: KcSpacing.sm),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
?action,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A stat / summary card that displays a [value] with a [label] and an [icon].
|
|
||||||
///
|
|
||||||
/// Used on the dashboard to show high-level KPIs such as total products,
|
|
||||||
/// in-stock count, etc. Optionally tappable via [onTap] to navigate to a
|
|
||||||
/// related page.
|
|
||||||
class SummaryCard extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final Color iconColor;
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
/// Optional tap handler for cross-feature navigation from dashboard KPIs.
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const SummaryCard({
|
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.iconColor,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
final card = KcCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: iconColor, size: 28),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text(value, style: theme.textTheme.headlineMedium?.copyWith(fontSize: 32)),
|
|
||||||
const SizedBox(height: KcSpacing.xs),
|
|
||||||
Text(label, style: theme.textTheme.bodyMedium?.copyWith(color: KcColors.neutral)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onTap == null) return card;
|
|
||||||
|
|
||||||
return MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: GestureDetector(onTap: onTap, child: card),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -78,27 +78,6 @@ packages:
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.0.1"
|
version: "0.0.1"
|
||||||
feature_orders:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "../../packages/feature_orders"
|
|
||||||
relative: true
|
|
||||||
source: path
|
|
||||||
version: "0.0.1"
|
|
||||||
feature_policy:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "../../packages/feature_policy"
|
|
||||||
relative: true
|
|
||||||
source: path
|
|
||||||
version: "0.0.1"
|
|
||||||
feature_wordpress:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "../../packages/feature_wordpress"
|
|
||||||
relative: true
|
|
||||||
source: path
|
|
||||||
version: "0.0.1"
|
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -117,22 +96,6 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
http:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http
|
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.6.0"
|
|
||||||
http_parser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http_parser
|
|
||||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.2"
|
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -250,14 +213,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.10"
|
||||||
typed_data:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: typed_data
|
|
||||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.0"
|
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -274,14 +229,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: web
|
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.4 <4.0.0"
|
dart: ">=3.11.4 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,6 @@ dependencies:
|
||||||
path: ../../packages/design_system
|
path: ../../packages/design_system
|
||||||
feature_inventory:
|
feature_inventory:
|
||||||
path: ../../packages/feature_inventory
|
path: ../../packages/feature_inventory
|
||||||
feature_orders:
|
|
||||||
path: ../../packages/feature_orders
|
|
||||||
feature_policy:
|
|
||||||
path: ../../packages/feature_policy
|
|
||||||
feature_wordpress:
|
|
||||||
path: ../../packages/feature_wordpress
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:kell_web/dashboard/application/dashboard_controller.dart';
|
|
||||||
import 'package:kell_web/dashboard/application/get_dashboard_summary.dart';
|
|
||||||
import 'package:kell_web/dashboard/domain/dashboard_summary.dart';
|
|
||||||
|
|
||||||
// ── Tiny stub repositories for testing ─────────────────────────────────────
|
|
||||||
|
|
||||||
class _StubInventoryRepository implements InventoryRepository {
|
|
||||||
final List<InventoryItem> items;
|
|
||||||
_StubInventoryRepository([this.items = const []]);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<InventoryItem>> getInventoryItems() async => items;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StubProductPublishingRepository implements ProductPublishingRepository {
|
|
||||||
final List<ProductDraft> drafts;
|
|
||||||
_StubProductPublishingRepository([this.drafts = const []]);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductDraft>> getProductDrafts() async => drafts;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StubOrdersRepository implements OrdersRepository {
|
|
||||||
final List<Order> orders;
|
|
||||||
_StubOrdersRepository([this.orders = const []]);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Order>> getOrders() async => orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FailingInventoryRepository implements InventoryRepository {
|
|
||||||
@override
|
|
||||||
Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('DashboardController', () {
|
|
||||||
test('starts with empty summary and not loading', () {
|
|
||||||
final controller = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: _StubInventoryRepository(),
|
|
||||||
productPublishingRepository: _StubProductPublishingRepository(),
|
|
||||||
ordersRepository: _StubOrdersRepository(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.summary, DashboardSummary.empty);
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load() sets isLoading then populates summary', () async {
|
|
||||||
final inventoryItems = [
|
|
||||||
const InventoryItem(
|
|
||||||
id: '1',
|
|
||||||
sku: 'A',
|
|
||||||
name: 'A',
|
|
||||||
quantityOnHand: 10,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
const InventoryItem(
|
|
||||||
id: '2',
|
|
||||||
sku: 'B',
|
|
||||||
name: 'B',
|
|
||||||
quantityOnHand: 2,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.lowStock,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final controller = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: _StubInventoryRepository(inventoryItems),
|
|
||||||
productPublishingRepository: _StubProductPublishingRepository(),
|
|
||||||
ordersRepository: _StubOrdersRepository(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track notification sequence.
|
|
||||||
final loadingStates = <bool>[];
|
|
||||||
controller.addListener(() => loadingStates.add(controller.isLoading));
|
|
||||||
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
// First notification: isLoading = true, second: isLoading = false.
|
|
||||||
expect(loadingStates, [true, false]);
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
expect(controller.summary.totalProducts, 2);
|
|
||||||
expect(controller.summary.inStock, 1);
|
|
||||||
expect(controller.summary.lowStock, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load() captures error and clears isLoading', () async {
|
|
||||||
final controller = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: _FailingInventoryRepository(),
|
|
||||||
productPublishingRepository: _StubProductPublishingRepository(),
|
|
||||||
ordersRepository: _StubOrdersRepository(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.error, isNotNull);
|
|
||||||
// Summary should remain empty on error.
|
|
||||||
expect(controller.summary, DashboardSummary.empty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load() aggregates data from all three repositories', () async {
|
|
||||||
final inventoryItems = [
|
|
||||||
const InventoryItem(
|
|
||||||
id: '1',
|
|
||||||
sku: 'A',
|
|
||||||
name: 'A',
|
|
||||||
quantityOnHand: 10,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final drafts = [
|
|
||||||
ProductDraft(
|
|
||||||
id: '1',
|
|
||||||
name: 'Draft',
|
|
||||||
description: '',
|
|
||||||
price: 10,
|
|
||||||
sku: 'D',
|
|
||||||
category: 'Cat',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final orders = [
|
|
||||||
Order(
|
|
||||||
id: '1',
|
|
||||||
customerName: 'Test',
|
|
||||||
customerEmail: 'test@test.com',
|
|
||||||
orderDate: DateTime(2026),
|
|
||||||
status: OrderStatus.pending,
|
|
||||||
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 10.0)],
|
|
||||||
shippingAddress: '',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final controller = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: _StubInventoryRepository(inventoryItems),
|
|
||||||
productPublishingRepository: _StubProductPublishingRepository(drafts),
|
|
||||||
ordersRepository: _StubOrdersRepository(orders),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
expect(controller.summary.totalProducts, 1);
|
|
||||||
expect(controller.summary.inStock, 1);
|
|
||||||
expect(controller.summary.draftProducts, 1);
|
|
||||||
expect(controller.summary.totalOrders, 1);
|
|
||||||
expect(controller.summary.pendingOrders, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('subsequent load() clears previous error', () async {
|
|
||||||
final failingController = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: _FailingInventoryRepository(),
|
|
||||||
productPublishingRepository: _StubProductPublishingRepository(),
|
|
||||||
ordersRepository: _StubOrdersRepository(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await failingController.load();
|
|
||||||
expect(failingController.error, isNotNull);
|
|
||||||
|
|
||||||
// Create a new controller with working repos to verify error clearing pattern.
|
|
||||||
final workingController = DashboardController(
|
|
||||||
GetDashboardSummary(
|
|
||||||
inventoryRepository: _StubInventoryRepository(),
|
|
||||||
productPublishingRepository: _StubProductPublishingRepository(),
|
|
||||||
ordersRepository: _StubOrdersRepository(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await workingController.load();
|
|
||||||
expect(workingController.error, isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:kell_web/dashboard/domain/dashboard_summary.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('DashboardSummary.empty', () {
|
|
||||||
test('has all zeroes', () {
|
|
||||||
const s = DashboardSummary.empty;
|
|
||||||
expect(s.totalProducts, 0);
|
|
||||||
expect(s.inStock, 0);
|
|
||||||
expect(s.lowStock, 0);
|
|
||||||
expect(s.outOfStock, 0);
|
|
||||||
expect(s.draftProducts, 0);
|
|
||||||
expect(s.totalOrders, 0);
|
|
||||||
expect(s.pendingOrders, 0);
|
|
||||||
expect(s.activeOrders, 0);
|
|
||||||
expect(s.deliveredRevenue, 0.0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('DashboardSummary.fromData', () {
|
|
||||||
test('counts inventory statuses correctly', () {
|
|
||||||
final items = [
|
|
||||||
const InventoryItem(
|
|
||||||
id: '1',
|
|
||||||
sku: 'A',
|
|
||||||
name: 'A',
|
|
||||||
quantityOnHand: 10,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
const InventoryItem(
|
|
||||||
id: '2',
|
|
||||||
sku: 'B',
|
|
||||||
name: 'B',
|
|
||||||
quantityOnHand: 2,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.lowStock,
|
|
||||||
),
|
|
||||||
const InventoryItem(
|
|
||||||
id: '3',
|
|
||||||
sku: 'C',
|
|
||||||
name: 'C',
|
|
||||||
quantityOnHand: 0,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.outOfStock,
|
|
||||||
),
|
|
||||||
const InventoryItem(
|
|
||||||
id: '4',
|
|
||||||
sku: 'D',
|
|
||||||
name: 'D',
|
|
||||||
quantityOnHand: 0,
|
|
||||||
unitPrice: 5.0,
|
|
||||||
status: InventoryStatus.draft,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final summary = DashboardSummary.fromData(
|
|
||||||
inventoryItems: items,
|
|
||||||
productDrafts: [],
|
|
||||||
orders: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(summary.totalProducts, 4);
|
|
||||||
expect(summary.inStock, 1);
|
|
||||||
expect(summary.lowStock, 1);
|
|
||||||
expect(summary.outOfStock, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts draft products from publishing repository', () {
|
|
||||||
final drafts = [
|
|
||||||
ProductDraft(
|
|
||||||
id: '1',
|
|
||||||
name: 'A',
|
|
||||||
description: '',
|
|
||||||
price: 10,
|
|
||||||
sku: 'A',
|
|
||||||
category: 'Cat',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '2',
|
|
||||||
name: 'B',
|
|
||||||
description: '',
|
|
||||||
price: 10,
|
|
||||||
sku: 'B',
|
|
||||||
category: 'Cat',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.published,
|
|
||||||
lastModified: DateTime(2026),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '3',
|
|
||||||
name: 'C',
|
|
||||||
description: '',
|
|
||||||
price: 10,
|
|
||||||
sku: 'C',
|
|
||||||
category: 'Cat',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final summary = DashboardSummary.fromData(
|
|
||||||
inventoryItems: [],
|
|
||||||
productDrafts: drafts,
|
|
||||||
orders: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(summary.draftProducts, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts order statuses and revenue correctly', () {
|
|
||||||
final orders = [
|
|
||||||
Order(
|
|
||||||
id: '1',
|
|
||||||
customerName: 'A',
|
|
||||||
customerEmail: 'a@a.com',
|
|
||||||
orderDate: DateTime(2026),
|
|
||||||
status: OrderStatus.pending,
|
|
||||||
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 10.0)],
|
|
||||||
shippingAddress: '',
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: '2',
|
|
||||||
customerName: 'B',
|
|
||||||
customerEmail: 'b@b.com',
|
|
||||||
orderDate: DateTime(2026),
|
|
||||||
status: OrderStatus.processing,
|
|
||||||
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 2, unitPrice: 5.0)],
|
|
||||||
shippingAddress: '',
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: '3',
|
|
||||||
customerName: 'C',
|
|
||||||
customerEmail: 'c@c.com',
|
|
||||||
orderDate: DateTime(2026),
|
|
||||||
status: OrderStatus.shipped,
|
|
||||||
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 20.0)],
|
|
||||||
shippingAddress: '',
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: '4',
|
|
||||||
customerName: 'D',
|
|
||||||
customerEmail: 'd@d.com',
|
|
||||||
orderDate: DateTime(2026),
|
|
||||||
status: OrderStatus.delivered,
|
|
||||||
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 3, unitPrice: 10.0)],
|
|
||||||
shippingAddress: '',
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: '5',
|
|
||||||
customerName: 'E',
|
|
||||||
customerEmail: 'e@e.com',
|
|
||||||
orderDate: DateTime(2026),
|
|
||||||
status: OrderStatus.cancelled,
|
|
||||||
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 100.0)],
|
|
||||||
shippingAddress: '',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final summary = DashboardSummary.fromData(
|
|
||||||
inventoryItems: [],
|
|
||||||
productDrafts: [],
|
|
||||||
orders: orders,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(summary.totalOrders, 5);
|
|
||||||
expect(summary.pendingOrders, 1);
|
|
||||||
expect(summary.activeOrders, 2); // processing + shipped
|
|
||||||
expect(summary.deliveredRevenue, 30.0); // 3 * 10
|
|
||||||
});
|
|
||||||
|
|
||||||
test('computes full summary from fake repository data', () {
|
|
||||||
// Use the same data the fake repositories return.
|
|
||||||
final summary = DashboardSummary.fromData(
|
|
||||||
inventoryItems: const [
|
|
||||||
InventoryItem(
|
|
||||||
id: '1',
|
|
||||||
sku: 'BC-FLR-001',
|
|
||||||
name: 'Floral Bowl Cozy',
|
|
||||||
quantityOnHand: 18,
|
|
||||||
unitPrice: 12.99,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '2',
|
|
||||||
sku: 'CS-CIT-002',
|
|
||||||
name: 'Citrus Coaster Set',
|
|
||||||
quantityOnHand: 7,
|
|
||||||
unitPrice: 16.50,
|
|
||||||
status: InventoryStatus.lowStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '3',
|
|
||||||
sku: 'NL-OCN-003',
|
|
||||||
name: 'Ocean Nightlight',
|
|
||||||
quantityOnHand: 0,
|
|
||||||
unitPrice: 19.99,
|
|
||||||
status: InventoryStatus.outOfStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '4',
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
name: 'Fabric Jar Gripper',
|
|
||||||
quantityOnHand: 23,
|
|
||||||
unitPrice: 8.50,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '5',
|
|
||||||
sku: 'SH-SUN-005',
|
|
||||||
name: 'Skillet Handle Sleeve',
|
|
||||||
quantityOnHand: 5,
|
|
||||||
unitPrice: 10.99,
|
|
||||||
status: InventoryStatus.lowStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '6',
|
|
||||||
sku: 'SC-SUB-006',
|
|
||||||
name: 'Sublimated Slate Coaster',
|
|
||||||
quantityOnHand: 0,
|
|
||||||
unitPrice: 14.99,
|
|
||||||
status: InventoryStatus.draft,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
productDrafts: [
|
|
||||||
ProductDraft(
|
|
||||||
id: '4',
|
|
||||||
name: 'Fabric Jar Gripper',
|
|
||||||
description: '',
|
|
||||||
price: 8.50,
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
category: 'Kitchen Accessories',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026, 4, 2),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '5',
|
|
||||||
name: 'Skillet Handle Sleeve',
|
|
||||||
description: '',
|
|
||||||
price: 10.99,
|
|
||||||
sku: 'SH-SUN-005',
|
|
||||||
category: 'Kitchen Accessories',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026, 4, 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
orders: [
|
|
||||||
Order(
|
|
||||||
id: 'KC-1001',
|
|
||||||
customerName: 'Sarah Mitchell',
|
|
||||||
customerEmail: 'sarah@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 1),
|
|
||||||
status: OrderStatus.delivered,
|
|
||||||
shippingAddress: '123 Maple St',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Floral Bowl Cozy',
|
|
||||||
sku: 'BC-FLR-001',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: 12.99,
|
|
||||||
),
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Citrus Coaster Set',
|
|
||||||
sku: 'CS-CIT-002',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 16.50,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1002',
|
|
||||||
customerName: 'James Thornton',
|
|
||||||
customerEmail: 'james@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 2),
|
|
||||||
status: OrderStatus.shipped,
|
|
||||||
shippingAddress: '456 Oak Ave',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Ocean Nightlight',
|
|
||||||
sku: 'NL-OCN-003',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 19.99,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1003',
|
|
||||||
customerName: 'Emily Chen',
|
|
||||||
customerEmail: 'emily@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 3),
|
|
||||||
status: OrderStatus.pending,
|
|
||||||
shippingAddress: '789 Pine Rd',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Fabric Jar Gripper',
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
quantity: 4,
|
|
||||||
unitPrice: 8.50,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(summary.totalProducts, 6);
|
|
||||||
expect(summary.inStock, 2);
|
|
||||||
expect(summary.lowStock, 2);
|
|
||||||
expect(summary.outOfStock, 1);
|
|
||||||
expect(summary.draftProducts, 2);
|
|
||||||
expect(summary.totalOrders, 3);
|
|
||||||
expect(summary.pendingOrders, 1);
|
|
||||||
expect(summary.activeOrders, 1); // shipped only
|
|
||||||
// delivered revenue: 2*12.99 + 1*16.50 = 42.48
|
|
||||||
expect(summary.deliveredRevenue, closeTo(42.48, 0.01));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:kell_web/navigation/navigation_target.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('NavigationTarget', () {
|
|
||||||
test('creates with route and empty arguments by default', () {
|
|
||||||
const target = NavigationTarget(route: '/inventory');
|
|
||||||
expect(target.route, '/inventory');
|
|
||||||
expect(target.arguments, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates with route and arguments', () {
|
|
||||||
const target = NavigationTarget(route: '/products', arguments: {'selectedSku': 'ABC-123'});
|
|
||||||
expect(target.route, '/products');
|
|
||||||
expect(target.arguments, {'selectedSku': 'ABC-123'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('convenience getters return correct values', () {
|
|
||||||
const target = NavigationTarget(
|
|
||||||
route: '/inventory',
|
|
||||||
arguments: {
|
|
||||||
'selectedSku': 'SKU-001',
|
|
||||||
'selectedId': 'ID-001',
|
|
||||||
'filter': 'lowStock',
|
|
||||||
'category': 'Product Compliance',
|
|
||||||
'query': 'coaster',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(target.selectedSku, 'SKU-001');
|
|
||||||
expect(target.selectedId, 'ID-001');
|
|
||||||
expect(target.filter, 'lowStock');
|
|
||||||
expect(target.category, 'Product Compliance');
|
|
||||||
expect(target.query, 'coaster');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('convenience getters return null when key is absent', () {
|
|
||||||
const target = NavigationTarget(route: '/orders');
|
|
||||||
expect(target.selectedSku, isNull);
|
|
||||||
expect(target.selectedId, isNull);
|
|
||||||
expect(target.filter, isNull);
|
|
||||||
expect(target.category, isNull);
|
|
||||||
expect(target.query, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('equality works for same route and arguments', () {
|
|
||||||
const a = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'});
|
|
||||||
const b = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'});
|
|
||||||
expect(a, equals(b));
|
|
||||||
expect(a.hashCode, equals(b.hashCode));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inequality for different routes', () {
|
|
||||||
const a = NavigationTarget(route: '/products');
|
|
||||||
const b = NavigationTarget(route: '/orders');
|
|
||||||
expect(a, isNot(equals(b)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inequality for different arguments', () {
|
|
||||||
const a = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'});
|
|
||||||
const b = NavigationTarget(route: '/products', arguments: {'selectedSku': 'Y'});
|
|
||||||
expect(a, isNot(equals(b)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toString includes route and arguments', () {
|
|
||||||
const target = NavigationTarget(route: '/inventory', arguments: {'filter': 'lowStock'});
|
|
||||||
expect(target.toString(), contains('/inventory'));
|
|
||||||
expect(target.toString(), contains('lowStock'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('query argument round-trips through convenience getter', () {
|
|
||||||
const target = NavigationTarget(route: '/inventory', arguments: {'query': 'bowl cozy'});
|
|
||||||
expect(target.query, 'bowl cozy');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +1,30 @@
|
||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
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/composition/app_config.dart';
|
|
||||||
import 'package:kell_web/composition/app_scope.dart';
|
|
||||||
import 'package:kell_web/composition/app_services.dart';
|
|
||||||
|
|
||||||
Widget _buildTestApp() {
|
import 'package:kell_web/main.dart';
|
||||||
const config = AppConfig(
|
|
||||||
environment: AppEnvironment.fake,
|
|
||||||
wcSiteUrl: '',
|
|
||||||
wcConsumerKey: '',
|
|
||||||
wcConsumerSecret: '',
|
|
||||||
);
|
|
||||||
return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('app shell loads dashboard route', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(_buildTestApp());
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
expect(find.text('Kell Creations'), findsOneWidget);
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('Dashboard'), findsWidgets);
|
expect(find.text('0'), findsOneWidget);
|
||||||
});
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
testWidgets('dashboard shows summary cards', (WidgetTester tester) async {
|
// Tap the '+' icon and trigger a frame.
|
||||||
await tester.pumpWidget(_buildTestApp());
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('Overview'), findsOneWidget);
|
// Verify that our counter has incremented.
|
||||||
expect(find.text('Total Products'), findsOneWidget);
|
expect(find.text('0'), findsNothing);
|
||||||
expect(find.text('In Stock'), findsOneWidget);
|
expect(find.text('1'), findsOneWidget);
|
||||||
expect(find.text('Low Stock'), findsOneWidget);
|
|
||||||
expect(find.text('Draft'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('dashboard shows quick actions section', (WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Scroll down to reveal the quick actions section.
|
|
||||||
await tester.scrollUntilVisible(
|
|
||||||
find.text('Quick Actions'),
|
|
||||||
200,
|
|
||||||
scrollable: find.byType(Scrollable).first,
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Quick Actions'), findsOneWidget);
|
|
||||||
expect(find.text('New Product'), findsOneWidget);
|
|
||||||
expect(find.text('View Inventory'), findsOneWidget);
|
|
||||||
expect(find.text('View Orders'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('dashboard shows recent activity empty state', (WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Scroll down to reveal the recent activity section.
|
|
||||||
await tester.scrollUntilVisible(
|
|
||||||
find.text('Recent Activity'),
|
|
||||||
200,
|
|
||||||
scrollable: find.byType(Scrollable).first,
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
library;
|
/// A Calculator.
|
||||||
|
class Calculator {
|
||||||
export 'src/theme/kc_colors.dart';
|
/// Returns [value] plus 1.
|
||||||
export 'src/theme/kc_spacing.dart';
|
int addOne(int value) => value + 1;
|
||||||
export 'src/theme/kc_theme.dart';
|
}
|
||||||
export 'src/widgets/kc_card.dart';
|
|
||||||
export 'src/widgets/kc_status_chip.dart';
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
abstract final class KcColors {
|
|
||||||
static const skyBlue = Color(0xFF55DDE0);
|
|
||||||
static const denimBlue = Color(0xFF33658A);
|
|
||||||
static const deepTeal = Color(0xFF2F4858);
|
|
||||||
static const honeyGold = Color(0xFFF6AE2D);
|
|
||||||
static const sunsetOrange = Color(0xFFF26419);
|
|
||||||
|
|
||||||
static const background = Color(0xFFF8FBFC);
|
|
||||||
static const surface = Colors.white;
|
|
||||||
static const border = Color(0xFFD9E4EA);
|
|
||||||
static const success = Color(0xFF2E7D32);
|
|
||||||
static const warning = Color(0xFFF9A825);
|
|
||||||
static const danger = Color(0xFFC62828);
|
|
||||||
static const neutral = Color(0xFF607D8B);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
abstract final class KcSpacing {
|
|
||||||
static const xs = 4.0;
|
|
||||||
static const sm = 8.0;
|
|
||||||
static const md = 16.0;
|
|
||||||
static const lg = 24.0;
|
|
||||||
static const xl = 32.0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'kc_colors.dart';
|
|
||||||
|
|
||||||
ThemeData buildKcTheme() {
|
|
||||||
final base = ThemeData(useMaterial3: true);
|
|
||||||
|
|
||||||
return base.copyWith(
|
|
||||||
scaffoldBackgroundColor: KcColors.background,
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: KcColors.denimBlue,
|
|
||||||
primary: KcColors.denimBlue,
|
|
||||||
secondary: KcColors.skyBlue,
|
|
||||||
surface: KcColors.surface,
|
|
||||||
),
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
backgroundColor: KcColors.surface,
|
|
||||||
foregroundColor: KcColors.deepTeal,
|
|
||||||
elevation: 0,
|
|
||||||
centerTitle: false,
|
|
||||||
),
|
|
||||||
cardTheme: const CardThemeData(
|
|
||||||
color: KcColors.surface,
|
|
||||||
elevation: 0,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
textTheme: base.textTheme.copyWith(
|
|
||||||
headlineMedium: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
titleLarge: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
bodyLarge: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
bodyMedium: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../theme/kc_colors.dart';
|
|
||||||
import '../theme/kc_spacing.dart';
|
|
||||||
|
|
||||||
class KcCard extends StatelessWidget {
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
|
|
||||||
const KcCard({super.key, required this.child, this.padding});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: padding ?? const EdgeInsets.all(KcSpacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: KcColors.surface,
|
|
||||||
border: Border.all(color: KcColors.border),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: const [BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000))],
|
|
||||||
),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class KcStatusChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final Color background;
|
|
||||||
final Color foreground;
|
|
||||||
|
|
||||||
const KcStatusChip({
|
|
||||||
super.key,
|
|
||||||
required this.label,
|
|
||||||
required this.background,
|
|
||||||
required this.foreground,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(999)),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 12),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('KcStatusChip', () {
|
test('adds one to input values', () {
|
||||||
testWidgets('renders label text', (WidgetTester tester) async {
|
final calculator = Calculator();
|
||||||
await tester.pumpWidget(
|
expect(calculator.addOne(2), 3);
|
||||||
const MaterialApp(
|
expect(calculator.addOne(-7), -6);
|
||||||
home: Scaffold(
|
expect(calculator.addOne(0), 1);
|
||||||
body: KcStatusChip(label: 'Active', background: Colors.green, foreground: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Active'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('KcCard', () {
|
|
||||||
testWidgets('renders child widget', (WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const MaterialApp(
|
|
||||||
home: Scaffold(body: KcCard(child: Text('Hello'))),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Hello'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('buildKcTheme', () {
|
|
||||||
test('returns a ThemeData', () {
|
|
||||||
final theme = buildKcTheme();
|
|
||||||
expect(theme, isA<ThemeData>());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
library;
|
/// A Calculator.
|
||||||
|
class Calculator {
|
||||||
export 'src/data/fake_inventory_repository.dart';
|
/// Returns [value] plus 1.
|
||||||
export 'src/domain/inventory_item.dart';
|
int addOne(int value) => value + 1;
|
||||||
export 'src/domain/inventory_repository.dart';
|
}
|
||||||
export 'src/domain/inventory_status.dart';
|
|
||||||
export 'src/presentation/inventory_page.dart';
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import '../domain/inventory_item.dart';
|
|
||||||
import '../domain/inventory_repository.dart';
|
|
||||||
|
|
||||||
class GetInventoryItems {
|
|
||||||
final InventoryRepository repository;
|
|
||||||
|
|
||||||
GetInventoryItems(this.repository);
|
|
||||||
|
|
||||||
Future<List<InventoryItem>> call() => repository.getInventoryItems();
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../domain/inventory_item.dart';
|
|
||||||
import '../domain/inventory_status.dart';
|
|
||||||
import 'get_inventory_items.dart';
|
|
||||||
|
|
||||||
/// Controller that manages the inventory workspace state, including
|
|
||||||
/// filtering by status, free-text search, and item selection.
|
|
||||||
class InventoryController extends ChangeNotifier {
|
|
||||||
final GetInventoryItems _getInventoryItems;
|
|
||||||
|
|
||||||
InventoryController(this._getInventoryItems);
|
|
||||||
|
|
||||||
bool isLoading = false;
|
|
||||||
Object? error;
|
|
||||||
|
|
||||||
/// All items returned by the repository (unfiltered).
|
|
||||||
List<InventoryItem> _allItems = [];
|
|
||||||
|
|
||||||
/// The currently visible items after applying [activeFilter] and [searchQuery].
|
|
||||||
List<InventoryItem> items = [];
|
|
||||||
|
|
||||||
/// The currently selected item, if any.
|
|
||||||
InventoryItem? selectedItem;
|
|
||||||
|
|
||||||
/// The active status filter label, or `null` for "all".
|
|
||||||
///
|
|
||||||
/// Recognised values: `'inStock'`, `'lowStock'`, `'outOfStock'`, `'draft'`.
|
|
||||||
String? activeFilter;
|
|
||||||
|
|
||||||
/// The current free-text search query applied to name / SKU.
|
|
||||||
String searchQuery = '';
|
|
||||||
|
|
||||||
/// Loads all inventory items and applies any current filter / search.
|
|
||||||
Future<void> load() async {
|
|
||||||
isLoading = true;
|
|
||||||
error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
_allItems = await _getInventoryItems();
|
|
||||||
_applyFilters();
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the status filter and recomputes the visible list.
|
|
||||||
void setFilter(String? filter) {
|
|
||||||
activeFilter = filter;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the search query and recomputes the visible list.
|
|
||||||
void setSearchQuery(String query) {
|
|
||||||
searchQuery = query;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects an item for detail view / highlight.
|
|
||||||
void selectItem(InventoryItem item) {
|
|
||||||
selectedItem = item;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to select an item by SKU. Returns `true` if found.
|
|
||||||
bool selectBySku(String sku) {
|
|
||||||
final match = _allItems.where((i) => i.sku == sku).firstOrNull;
|
|
||||||
if (match != null) {
|
|
||||||
selectedItem = match;
|
|
||||||
notifyListeners();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void _applyFilters() {
|
|
||||||
var result = _allItems;
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
final status = _parseStatus(activeFilter);
|
|
||||||
if (status != null) {
|
|
||||||
result = result.where((i) => i.status == status).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free-text search on name and SKU
|
|
||||||
if (searchQuery.isNotEmpty) {
|
|
||||||
final q = searchQuery.toLowerCase();
|
|
||||||
result = result.where((i) {
|
|
||||||
return i.name.toLowerCase().contains(q) || i.sku.toLowerCase().contains(q);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
items = result;
|
|
||||||
|
|
||||||
// Keep selection valid; clear if the selected item is no longer visible.
|
|
||||||
if (selectedItem != null && !items.contains(selectedItem)) {
|
|
||||||
selectedItem = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static InventoryStatus? _parseStatus(String? filter) {
|
|
||||||
if (filter == null) return null;
|
|
||||||
switch (filter) {
|
|
||||||
case 'inStock':
|
|
||||||
return InventoryStatus.inStock;
|
|
||||||
case 'lowStock':
|
|
||||||
return InventoryStatus.lowStock;
|
|
||||||
case 'outOfStock':
|
|
||||||
return InventoryStatus.outOfStock;
|
|
||||||
case 'draft':
|
|
||||||
return InventoryStatus.draft;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import '../domain/inventory_item.dart';
|
|
||||||
import '../domain/inventory_repository.dart';
|
|
||||||
import '../domain/inventory_status.dart';
|
|
||||||
|
|
||||||
class FakeInventoryRepository implements InventoryRepository {
|
|
||||||
@override
|
|
||||||
Future<List<InventoryItem>> getInventoryItems() async {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
return const [
|
|
||||||
InventoryItem(
|
|
||||||
id: '1',
|
|
||||||
sku: 'BC-FLR-001',
|
|
||||||
name: 'Floral Bowl Cozy',
|
|
||||||
quantityOnHand: 18,
|
|
||||||
unitPrice: 12.99,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '2',
|
|
||||||
sku: 'CS-CIT-002',
|
|
||||||
name: 'Citrus Coaster Set',
|
|
||||||
quantityOnHand: 7,
|
|
||||||
unitPrice: 16.50,
|
|
||||||
status: InventoryStatus.lowStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '3',
|
|
||||||
sku: 'NL-OCN-003',
|
|
||||||
name: 'Ocean Nightlight',
|
|
||||||
quantityOnHand: 0,
|
|
||||||
unitPrice: 19.99,
|
|
||||||
status: InventoryStatus.outOfStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '4',
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
name: 'Fabric Jar Gripper',
|
|
||||||
quantityOnHand: 23,
|
|
||||||
unitPrice: 8.50,
|
|
||||||
status: InventoryStatus.inStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '5',
|
|
||||||
sku: 'SH-SUN-005',
|
|
||||||
name: 'Skillet Handle Sleeve',
|
|
||||||
quantityOnHand: 5,
|
|
||||||
unitPrice: 10.99,
|
|
||||||
status: InventoryStatus.lowStock,
|
|
||||||
),
|
|
||||||
InventoryItem(
|
|
||||||
id: '6',
|
|
||||||
sku: 'SC-SUB-006',
|
|
||||||
name: 'Sublimated Slate Coaster',
|
|
||||||
quantityOnHand: 0,
|
|
||||||
unitPrice: 14.99,
|
|
||||||
status: InventoryStatus.draft,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import 'inventory_status.dart';
|
|
||||||
|
|
||||||
class InventoryItem {
|
|
||||||
final String id;
|
|
||||||
final String sku;
|
|
||||||
final String name;
|
|
||||||
final int quantityOnHand;
|
|
||||||
final double unitPrice;
|
|
||||||
final InventoryStatus status;
|
|
||||||
|
|
||||||
const InventoryItem({
|
|
||||||
required this.id,
|
|
||||||
required this.sku,
|
|
||||||
required this.name,
|
|
||||||
required this.quantityOnHand,
|
|
||||||
required this.unitPrice,
|
|
||||||
required this.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import 'inventory_item.dart';
|
|
||||||
|
|
||||||
abstract class InventoryRepository {
|
|
||||||
Future<List<InventoryItem>> getInventoryItems();
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
enum InventoryStatus { inStock, lowStock, outOfStock, draft }
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../application/get_inventory_items.dart';
|
|
||||||
import '../application/inventory_controller.dart';
|
|
||||||
import '../domain/inventory_repository.dart';
|
|
||||||
import 'widgets/inventory_item_card.dart';
|
|
||||||
|
|
||||||
class InventoryPage extends StatefulWidget {
|
|
||||||
final InventoryRepository repository;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Products page for a given SKU.
|
|
||||||
/// Provided by the app layer to enable cross-feature handoffs.
|
|
||||||
final void Function(String sku)? onViewProduct;
|
|
||||||
|
|
||||||
/// Optional initial status filter to apply on first load (e.g. `'lowStock'`).
|
|
||||||
final String? initialFilter;
|
|
||||||
|
|
||||||
/// Optional initial search query to apply on first load.
|
|
||||||
final String? initialQuery;
|
|
||||||
|
|
||||||
/// Optional SKU to pre-select on first load (from a navigation handoff).
|
|
||||||
final String? initialSelectedSku;
|
|
||||||
|
|
||||||
const InventoryPage({
|
|
||||||
super.key,
|
|
||||||
required this.repository,
|
|
||||||
this.onViewProduct,
|
|
||||||
this.initialFilter,
|
|
||||||
this.initialQuery,
|
|
||||||
this.initialSelectedSku,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<InventoryPage> createState() => _InventoryPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InventoryPageState extends State<InventoryPage> {
|
|
||||||
late final InventoryController controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = InventoryController(GetInventoryItems(widget.repository));
|
|
||||||
|
|
||||||
// Apply any initial filter / query before loading.
|
|
||||||
if (widget.initialFilter != null) {
|
|
||||||
controller.activeFilter = widget.initialFilter;
|
|
||||||
}
|
|
||||||
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
|
||||||
controller.searchQuery = widget.initialQuery!;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.load().then((_) {
|
|
||||||
// After data is loaded, try to pre-select by SKU if requested.
|
|
||||||
if (widget.initialSelectedSku != null) {
|
|
||||||
controller.selectBySku(widget.initialSelectedSku!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
if (controller.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller.error != null) {
|
|
||||||
return const Center(child: Text('Failed to load inventory data.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth;
|
|
||||||
|
|
||||||
int crossAxisCount = 1;
|
|
||||||
if (width >= 1200) {
|
|
||||||
crossAxisCount = 3;
|
|
||||||
} else if (width >= 700) {
|
|
||||||
crossAxisCount = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GridView.builder(
|
|
||||||
itemCount: controller.items.length,
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: crossAxisCount,
|
|
||||||
crossAxisSpacing: KcSpacing.md,
|
|
||||||
mainAxisSpacing: KcSpacing.md,
|
|
||||||
childAspectRatio: 1.5,
|
|
||||||
),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = controller.items[index];
|
|
||||||
return InventoryItemCard(
|
|
||||||
item: item,
|
|
||||||
onViewProduct: widget.onViewProduct != null
|
|
||||||
? () => widget.onViewProduct!(item.sku)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../domain/inventory_item.dart';
|
|
||||||
import '../../domain/inventory_status.dart';
|
|
||||||
|
|
||||||
class InventoryItemCard extends StatelessWidget {
|
|
||||||
final InventoryItem item;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Products page for this item's SKU.
|
|
||||||
final VoidCallback? onViewProduct;
|
|
||||||
|
|
||||||
const InventoryItemCard({super.key, required this.item, this.onViewProduct});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final (label, bg, fg) = _statusStyle(item.status);
|
|
||||||
|
|
||||||
return KcCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(item.name, style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text('SKU: ${item.sku}'),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
KcStatusChip(label: label, background: bg, foreground: fg),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
Text('Quantity on hand: ${item.quantityOnHand}'),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text('Unit price: \$${item.unitPrice.toStringAsFixed(2)}'),
|
|
||||||
if (onViewProduct != null) ...[
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onViewProduct,
|
|
||||||
child: Text(
|
|
||||||
'View Product →',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: KcColors.denimBlue,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
(String, Color, Color) _statusStyle(InventoryStatus status) {
|
|
||||||
switch (status) {
|
|
||||||
case InventoryStatus.inStock:
|
|
||||||
return ('In stock', const Color(0xFFE8F5E9), KcColors.success);
|
|
||||||
case InventoryStatus.lowStock:
|
|
||||||
return ('Low stock', const Color(0xFFFFF8E1), KcColors.warning);
|
|
||||||
case InventoryStatus.outOfStock:
|
|
||||||
return ('Out of stock', const Color(0xFFFFEBEE), KcColors.danger);
|
|
||||||
case InventoryStatus.draft:
|
|
||||||
return ('Draft', const Color(0xFFECEFF1), KcColors.neutral);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
name: feature_inventory
|
name: feature_inventory
|
||||||
description: "A new Flutter package project."
|
description: "A new Flutter package project."
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
publish_to: "none"
|
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -11,8 +10,6 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
design_system:
|
|
||||||
path: ../design_system
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,12 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
import 'package:feature_inventory/feature_inventory.dart';
|
||||||
import 'package:feature_inventory/src/application/get_inventory_items.dart';
|
|
||||||
import 'package:feature_inventory/src/application/inventory_controller.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('InventoryStatus', () {
|
test('adds one to input values', () {
|
||||||
test('has four values', () {
|
final calculator = Calculator();
|
||||||
expect(InventoryStatus.values.length, 4);
|
expect(calculator.addOne(2), 3);
|
||||||
});
|
expect(calculator.addOne(-7), -6);
|
||||||
|
expect(calculator.addOne(0), 1);
|
||||||
test('contains expected statuses', () {
|
|
||||||
expect(InventoryStatus.values, contains(InventoryStatus.inStock));
|
|
||||||
expect(InventoryStatus.values, contains(InventoryStatus.lowStock));
|
|
||||||
expect(InventoryStatus.values, contains(InventoryStatus.outOfStock));
|
|
||||||
expect(InventoryStatus.values, contains(InventoryStatus.draft));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('FakeInventoryRepository', () {
|
|
||||||
late FakeInventoryRepository repository;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
repository = FakeInventoryRepository();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns six sample items', () async {
|
|
||||||
final items = await repository.getInventoryItems();
|
|
||||||
expect(items.length, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns items with expected names', () async {
|
|
||||||
final items = await repository.getInventoryItems();
|
|
||||||
final names = items.map((i) => i.name).toList();
|
|
||||||
expect(names, contains('Floral Bowl Cozy'));
|
|
||||||
expect(names, contains('Citrus Coaster Set'));
|
|
||||||
expect(names, contains('Ocean Nightlight'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('InventoryController', () {
|
|
||||||
late FakeInventoryRepository repository;
|
|
||||||
late InventoryController controller;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
repository = FakeInventoryRepository();
|
|
||||||
controller = InventoryController(GetInventoryItems(repository));
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() {
|
|
||||||
controller.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starts with empty state', () {
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.items, isEmpty);
|
|
||||||
expect(controller.selectedItem, isNull);
|
|
||||||
expect(controller.activeFilter, isNull);
|
|
||||||
expect(controller.searchQuery, '');
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load populates items', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.items.length, 6);
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setFilter filters by status', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setFilter('lowStock');
|
|
||||||
expect(controller.items.length, 2);
|
|
||||||
expect(controller.items.every((i) => i.status == InventoryStatus.lowStock), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setFilter with null shows all items', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setFilter('lowStock');
|
|
||||||
expect(controller.items.length, 2);
|
|
||||||
|
|
||||||
controller.setFilter(null);
|
|
||||||
expect(controller.items.length, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSearchQuery filters by name', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('cozy');
|
|
||||||
expect(controller.items.length, 1);
|
|
||||||
expect(controller.items.first.name, 'Floral Bowl Cozy');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSearchQuery filters by SKU', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('BC-FLR');
|
|
||||||
expect(controller.items.length, 1);
|
|
||||||
expect(controller.items.first.sku, 'BC-FLR-001');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('search is case-insensitive', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('OCEAN');
|
|
||||||
expect(controller.items.length, 1);
|
|
||||||
expect(controller.items.first.name, 'Ocean Nightlight');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filter and search combine', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setFilter('inStock');
|
|
||||||
controller.setSearchQuery('floral');
|
|
||||||
expect(controller.items.length, 1);
|
|
||||||
expect(controller.items.first.name, 'Floral Bowl Cozy');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectItem sets selectedItem', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final item = controller.items[2];
|
|
||||||
controller.selectItem(item);
|
|
||||||
expect(controller.selectedItem, item);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectBySku selects matching item', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final found = controller.selectBySku('CS-CIT-002');
|
|
||||||
expect(found, true);
|
|
||||||
expect(controller.selectedItem!.sku, 'CS-CIT-002');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectBySku returns false for unknown SKU', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final found = controller.selectBySku('UNKNOWN');
|
|
||||||
expect(found, false);
|
|
||||||
expect(controller.selectedItem, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selection is cleared when filtered out', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
// Select an inStock item.
|
|
||||||
controller.selectBySku('BC-FLR-001');
|
|
||||||
expect(controller.selectedItem, isNotNull);
|
|
||||||
|
|
||||||
// Filter to lowStock — the selected item should be cleared.
|
|
||||||
controller.setFilter('lowStock');
|
|
||||||
expect(controller.selectedItem, isNull);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
{
|
|
||||||
"configVersion": 2,
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/async-2.13.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boolean_selector",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/boolean_selector-2.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "characters",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/characters-1.4.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "clock",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/clock-1.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/collection-1.19.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "design_system",
|
|
||||||
"rootUri": "../../design_system",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "fake_async",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/fake_async-1.3.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flutter",
|
|
||||||
"rootUri": "file:///D:/develop/flutter/packages/flutter",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.9"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flutter_lints",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_lints-6.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flutter_test",
|
|
||||||
"rootUri": "file:///D:/develop/flutter/packages/flutter_test",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.9"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker-11.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker_flutter_testing",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker_testing",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "lints",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/lints-6.1.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "matcher",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.19",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.7"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "material_color_utilities",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/material_color_utilities-0.13.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.17.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/path-1.9.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sky_engine",
|
|
||||||
"rootUri": "file:///D:/develop/flutter/bin/cache/pkg/sky_engine",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.9"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/source_span-1.10.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/stack_trace-1.12.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/stream_channel-2.1.4",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/string_scanner-1.4.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/term_glyph-1.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_api",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.10",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.7"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vector_math",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vm_service-15.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "feature_orders",
|
|
||||||
"rootUri": "../",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generator": "pub",
|
|
||||||
"generatorVersion": "3.11.4",
|
|
||||||
"flutterRoot": "file:///D:/develop/flutter",
|
|
||||||
"flutterVersion": "3.41.6",
|
|
||||||
"pubCache": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache"
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
{
|
|
||||||
"roots": [
|
|
||||||
"feature_orders"
|
|
||||||
],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "feature_orders",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"dependencies": [
|
|
||||||
"design_system",
|
|
||||||
"flutter"
|
|
||||||
],
|
|
||||||
"devDependencies": [
|
|
||||||
"flutter_lints",
|
|
||||||
"flutter_test"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flutter_lints",
|
|
||||||
"version": "6.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"lints"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flutter_test",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"clock",
|
|
||||||
"collection",
|
|
||||||
"fake_async",
|
|
||||||
"flutter",
|
|
||||||
"leak_tracker_flutter_testing",
|
|
||||||
"matcher",
|
|
||||||
"meta",
|
|
||||||
"path",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"test_api",
|
|
||||||
"vector_math"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "design_system",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"dependencies": [
|
|
||||||
"flutter"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flutter",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"characters",
|
|
||||||
"collection",
|
|
||||||
"material_color_utilities",
|
|
||||||
"meta",
|
|
||||||
"sky_engine",
|
|
||||||
"vector_math"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "lints",
|
|
||||||
"version": "6.1.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"version": "2.1.4",
|
|
||||||
"dependencies": [
|
|
||||||
"async"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"version": "1.17.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"version": "1.19.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker_flutter_testing",
|
|
||||||
"version": "3.0.10",
|
|
||||||
"dependencies": [
|
|
||||||
"flutter",
|
|
||||||
"leak_tracker",
|
|
||||||
"leak_tracker_testing",
|
|
||||||
"matcher",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vector_math",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"version": "1.12.1",
|
|
||||||
"dependencies": [
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "clock",
|
|
||||||
"version": "1.1.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "fake_async",
|
|
||||||
"version": "1.3.3",
|
|
||||||
"dependencies": [
|
|
||||||
"clock",
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"version": "1.9.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "matcher",
|
|
||||||
"version": "0.12.19",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"meta",
|
|
||||||
"stack_trace",
|
|
||||||
"term_glyph",
|
|
||||||
"test_api"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_api",
|
|
||||||
"version": "0.7.10",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"meta",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"string_scanner",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sky_engine",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "material_color_utilities",
|
|
||||||
"version": "0.13.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "characters",
|
|
||||||
"version": "1.4.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"version": "2.13.1",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker_testing",
|
|
||||||
"version": "3.0.2",
|
|
||||||
"dependencies": [
|
|
||||||
"leak_tracker",
|
|
||||||
"matcher",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker",
|
|
||||||
"version": "11.0.2",
|
|
||||||
"dependencies": [
|
|
||||||
"clock",
|
|
||||||
"collection",
|
|
||||||
"meta",
|
|
||||||
"path",
|
|
||||||
"vm_service"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"version": "1.2.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"version": "1.4.1",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"version": "1.10.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"path",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boolean_selector",
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"version": "15.0.2",
|
|
||||||
"dependencies": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configVersion": 1
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
3.41.6
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
include: package:flutter_lints/flutter.yaml
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
|
||||||
# https://dart.dev/guides/language/analysis-options
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"format-version":[1,0,0],"native-assets":{}}
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1 +0,0 @@
|
||||||
[]
|
|
||||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
||||||
{"format-version":[1,0,0],"native-assets":{}}
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,8 +0,0 @@
|
||||||
library;
|
|
||||||
|
|
||||||
export 'src/data/fake_orders_repository.dart';
|
|
||||||
export 'src/domain/order.dart';
|
|
||||||
export 'src/domain/order_item.dart';
|
|
||||||
export 'src/domain/order_status.dart';
|
|
||||||
export 'src/domain/orders_repository.dart';
|
|
||||||
export 'src/presentation/orders_page.dart';
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import '../domain/order.dart';
|
|
||||||
import '../domain/orders_repository.dart';
|
|
||||||
|
|
||||||
/// Use case: retrieve all orders from the repository.
|
|
||||||
class GetOrders {
|
|
||||||
final OrdersRepository repository;
|
|
||||||
|
|
||||||
GetOrders(this.repository);
|
|
||||||
|
|
||||||
Future<List<Order>> call() => repository.getOrders();
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../domain/order.dart';
|
|
||||||
import '../domain/order_status.dart';
|
|
||||||
import 'get_orders.dart';
|
|
||||||
|
|
||||||
/// Controller that manages the orders workspace state, including
|
|
||||||
/// filtering by order status, free-text search, and order selection.
|
|
||||||
class OrdersController extends ChangeNotifier {
|
|
||||||
final GetOrders _getOrders;
|
|
||||||
|
|
||||||
OrdersController(this._getOrders);
|
|
||||||
|
|
||||||
bool isLoading = false;
|
|
||||||
Object? error;
|
|
||||||
|
|
||||||
/// All orders returned by the repository (unfiltered).
|
|
||||||
List<Order> _allOrders = [];
|
|
||||||
|
|
||||||
/// The currently visible orders after applying [activeFilter] and [searchQuery].
|
|
||||||
List<Order> orders = [];
|
|
||||||
|
|
||||||
/// The currently selected order for detail view.
|
|
||||||
Order? selectedOrder;
|
|
||||||
|
|
||||||
/// The active status filter label, or `null` for "all".
|
|
||||||
///
|
|
||||||
/// Recognised values: `'pending'`, `'processing'`, `'shipped'`,
|
|
||||||
/// `'delivered'`, `'cancelled'`.
|
|
||||||
String? activeFilter;
|
|
||||||
|
|
||||||
/// The current free-text search query applied to customer name / order ID.
|
|
||||||
String searchQuery = '';
|
|
||||||
|
|
||||||
/// Loads all orders and applies any current filter / search.
|
|
||||||
Future<void> load() async {
|
|
||||||
isLoading = true;
|
|
||||||
error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
_allOrders = await _getOrders();
|
|
||||||
_applyFilters();
|
|
||||||
// Auto-select the first order if nothing is selected.
|
|
||||||
selectedOrder ??= orders.isNotEmpty ? orders.first : null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the status filter and recomputes the visible list.
|
|
||||||
void setFilter(String? filter) {
|
|
||||||
activeFilter = filter;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the search query and recomputes the visible list.
|
|
||||||
void setSearchQuery(String query) {
|
|
||||||
searchQuery = query;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects an order for detail view.
|
|
||||||
void selectOrder(Order order) {
|
|
||||||
selectedOrder = order;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to select an order by ID. Returns `true` if found.
|
|
||||||
bool selectById(String id) {
|
|
||||||
final match = _allOrders.where((o) => o.id == id).firstOrNull;
|
|
||||||
if (match != null) {
|
|
||||||
selectedOrder = match;
|
|
||||||
notifyListeners();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void _applyFilters() {
|
|
||||||
var result = _allOrders;
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
final status = _parseStatus(activeFilter);
|
|
||||||
if (status != null) {
|
|
||||||
result = result.where((o) => o.status == status).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free-text search on customer name and order ID
|
|
||||||
if (searchQuery.isNotEmpty) {
|
|
||||||
final q = searchQuery.toLowerCase();
|
|
||||||
result = result.where((o) {
|
|
||||||
return o.customerName.toLowerCase().contains(q) || o.id.toLowerCase().contains(q);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
orders = result;
|
|
||||||
|
|
||||||
// Keep selection valid; clear if the selected order is no longer visible.
|
|
||||||
if (selectedOrder != null && !orders.any((o) => o.id == selectedOrder!.id)) {
|
|
||||||
selectedOrder = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static OrderStatus? _parseStatus(String? filter) {
|
|
||||||
if (filter == null) return null;
|
|
||||||
switch (filter) {
|
|
||||||
case 'pending':
|
|
||||||
return OrderStatus.pending;
|
|
||||||
case 'processing':
|
|
||||||
return OrderStatus.processing;
|
|
||||||
case 'shipped':
|
|
||||||
return OrderStatus.shipped;
|
|
||||||
case 'delivered':
|
|
||||||
return OrderStatus.delivered;
|
|
||||||
case 'cancelled':
|
|
||||||
return OrderStatus.cancelled;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
import '../domain/order.dart';
|
|
||||||
import '../domain/order_item.dart';
|
|
||||||
import '../domain/order_status.dart';
|
|
||||||
import '../domain/orders_repository.dart';
|
|
||||||
|
|
||||||
/// Stubbed implementation of [OrdersRepository] with sample
|
|
||||||
/// Kell Creations orders. No real WooCommerce or shipping API calls are made.
|
|
||||||
class FakeOrdersRepository implements OrdersRepository {
|
|
||||||
final List<Order> _orders = [
|
|
||||||
Order(
|
|
||||||
id: 'KC-1001',
|
|
||||||
customerName: 'Sarah Mitchell',
|
|
||||||
customerEmail: 'sarah.mitchell@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 1),
|
|
||||||
status: OrderStatus.delivered,
|
|
||||||
shippingAddress: '123 Maple St, Asheville, NC 28801',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Floral Bowl Cozy',
|
|
||||||
sku: 'BC-FLR-001',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: 12.99,
|
|
||||||
),
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Citrus Coaster Set',
|
|
||||||
sku: 'CS-CIT-002',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 16.50,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1002',
|
|
||||||
customerName: 'James Thornton',
|
|
||||||
customerEmail: 'james.thornton@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 2),
|
|
||||||
status: OrderStatus.shipped,
|
|
||||||
shippingAddress: '456 Oak Ave, Knoxville, TN 37902',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Ocean Nightlight',
|
|
||||||
sku: 'NL-OCN-003',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 19.99,
|
|
||||||
),
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Sublimated Slate Coaster',
|
|
||||||
sku: 'SC-SUB-006',
|
|
||||||
quantity: 3,
|
|
||||||
unitPrice: 14.99,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1003',
|
|
||||||
customerName: 'Emily Chen',
|
|
||||||
customerEmail: 'emily.chen@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 3),
|
|
||||||
status: OrderStatus.processing,
|
|
||||||
shippingAddress: '789 Pine Rd, Charlotte, NC 28202',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Fabric Jar Gripper',
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
quantity: 4,
|
|
||||||
unitPrice: 8.50,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1004',
|
|
||||||
customerName: 'David Park',
|
|
||||||
customerEmail: 'david.park@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 3),
|
|
||||||
status: OrderStatus.pending,
|
|
||||||
shippingAddress: '321 Birch Ln, Greenville, SC 29601',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Skillet Handle Sleeve',
|
|
||||||
sku: 'SH-SUN-005',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: 10.99,
|
|
||||||
),
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Floral Bowl Cozy',
|
|
||||||
sku: 'BC-FLR-001',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 12.99,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1005',
|
|
||||||
customerName: 'Rachel Adams',
|
|
||||||
customerEmail: 'rachel.adams@example.com',
|
|
||||||
orderDate: DateTime(2026, 3, 28),
|
|
||||||
status: OrderStatus.cancelled,
|
|
||||||
shippingAddress: '654 Elm St, Richmond, VA 23220',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Citrus Coaster Set',
|
|
||||||
sku: 'CS-CIT-002',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: 16.50,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Order(
|
|
||||||
id: 'KC-1006',
|
|
||||||
customerName: 'Maria Gonzalez',
|
|
||||||
customerEmail: 'maria.gonzalez@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 4),
|
|
||||||
status: OrderStatus.pending,
|
|
||||||
shippingAddress: '987 Cedar Dr, Atlanta, GA 30301',
|
|
||||||
items: const [
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Ocean Nightlight',
|
|
||||||
sku: 'NL-OCN-003',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 19.99,
|
|
||||||
),
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Fabric Jar Gripper',
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: 8.50,
|
|
||||||
),
|
|
||||||
OrderItem(
|
|
||||||
productName: 'Sublimated Slate Coaster',
|
|
||||||
sku: 'SC-SUB-006',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 14.99,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Order>> getOrders() async {
|
|
||||||
// Simulate network latency.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
||||||
return List.unmodifiable(_orders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import 'order_item.dart';
|
|
||||||
import 'order_status.dart';
|
|
||||||
|
|
||||||
/// A customer order placed through the Kell Creations store.
|
|
||||||
class Order {
|
|
||||||
final String id;
|
|
||||||
final String customerName;
|
|
||||||
final String customerEmail;
|
|
||||||
final DateTime orderDate;
|
|
||||||
final OrderStatus status;
|
|
||||||
final List<OrderItem> items;
|
|
||||||
final String shippingAddress;
|
|
||||||
|
|
||||||
const Order({
|
|
||||||
required this.id,
|
|
||||||
required this.customerName,
|
|
||||||
required this.customerEmail,
|
|
||||||
required this.orderDate,
|
|
||||||
required this.status,
|
|
||||||
required this.items,
|
|
||||||
required this.shippingAddress,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The total value of the order.
|
|
||||||
double get total => items.fold(0, (sum, item) => sum + item.lineTotal);
|
|
||||||
|
|
||||||
/// The number of individual items in the order.
|
|
||||||
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/// A single line item within an [Order].
|
|
||||||
class OrderItem {
|
|
||||||
final String productName;
|
|
||||||
final String sku;
|
|
||||||
final int quantity;
|
|
||||||
final double unitPrice;
|
|
||||||
|
|
||||||
const OrderItem({
|
|
||||||
required this.productName,
|
|
||||||
required this.sku,
|
|
||||||
required this.quantity,
|
|
||||||
required this.unitPrice,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The total price for this line item.
|
|
||||||
double get lineTotal => quantity * unitPrice;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/// The fulfilment status of a customer order.
|
|
||||||
enum OrderStatus {
|
|
||||||
/// Order has been placed but not yet processed.
|
|
||||||
pending,
|
|
||||||
|
|
||||||
/// Order is being prepared / packed.
|
|
||||||
processing,
|
|
||||||
|
|
||||||
/// Order has been shipped to the customer.
|
|
||||||
shipped,
|
|
||||||
|
|
||||||
/// Order has been delivered.
|
|
||||||
delivered,
|
|
||||||
|
|
||||||
/// Order was cancelled before fulfilment.
|
|
||||||
cancelled,
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import 'order.dart';
|
|
||||||
|
|
||||||
/// Contract for fetching and managing customer orders.
|
|
||||||
abstract class OrdersRepository {
|
|
||||||
/// Returns all orders.
|
|
||||||
Future<List<Order>> getOrders();
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../application/get_orders.dart';
|
|
||||||
import '../application/orders_controller.dart';
|
|
||||||
import '../domain/orders_repository.dart';
|
|
||||||
import 'widgets/order_card.dart';
|
|
||||||
import 'widgets/order_detail_panel.dart';
|
|
||||||
|
|
||||||
/// The main Orders page.
|
|
||||||
///
|
|
||||||
/// Displays a list of orders on the left and a detail panel on the right.
|
|
||||||
/// Users can select an order to view its full details.
|
|
||||||
class OrdersPage extends StatefulWidget {
|
|
||||||
final OrdersRepository repository;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Products page for a given SKU.
|
|
||||||
final void Function(String sku)? onViewProduct;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Inventory page for a given SKU.
|
|
||||||
final void Function(String sku)? onViewInventory;
|
|
||||||
|
|
||||||
/// Optional initial status filter to apply on first load (e.g. `'pending'`).
|
|
||||||
final String? initialFilter;
|
|
||||||
|
|
||||||
/// Optional initial search query to apply on first load.
|
|
||||||
final String? initialQuery;
|
|
||||||
|
|
||||||
/// Optional order ID to pre-select on first load (from a navigation handoff).
|
|
||||||
final String? initialSelectedId;
|
|
||||||
|
|
||||||
const OrdersPage({
|
|
||||||
super.key,
|
|
||||||
required this.repository,
|
|
||||||
this.onViewProduct,
|
|
||||||
this.onViewInventory,
|
|
||||||
this.initialFilter,
|
|
||||||
this.initialQuery,
|
|
||||||
this.initialSelectedId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<OrdersPage> createState() => _OrdersPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OrdersPageState extends State<OrdersPage> {
|
|
||||||
late final OrdersController controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = OrdersController(GetOrders(widget.repository));
|
|
||||||
|
|
||||||
// Apply any initial filter / query before loading.
|
|
||||||
if (widget.initialFilter != null) {
|
|
||||||
controller.activeFilter = widget.initialFilter;
|
|
||||||
}
|
|
||||||
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
|
||||||
controller.searchQuery = widget.initialQuery!;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.load().then((_) {
|
|
||||||
// After data is loaded, try to pre-select by ID if requested.
|
|
||||||
if (widget.initialSelectedId != null) {
|
|
||||||
controller.selectById(widget.initialSelectedId!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
if (controller.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller.error != null) {
|
|
||||||
return const Center(child: Text('Failed to load orders.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
// On narrow screens show only the list; on wide screens show
|
|
||||||
// a master-detail layout.
|
|
||||||
if (constraints.maxWidth < 800) {
|
|
||||||
return _buildOrderList();
|
|
||||||
}
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(width: 380, child: _buildOrderList()),
|
|
||||||
const SizedBox(width: KcSpacing.md),
|
|
||||||
Expanded(child: _buildDetail()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildOrderList() {
|
|
||||||
return ListView.separated(
|
|
||||||
itemCount: controller.orders.length,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final order = controller.orders[index];
|
|
||||||
return SizedBox(
|
|
||||||
height: 160,
|
|
||||||
child: OrderCard(
|
|
||||||
order: order,
|
|
||||||
isSelected: order.id == controller.selectedOrder?.id,
|
|
||||||
onTap: () => controller.selectOrder(order),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetail() {
|
|
||||||
final selected = controller.selectedOrder;
|
|
||||||
if (selected == null) {
|
|
||||||
return const Center(child: Text('Select an order to view details'));
|
|
||||||
}
|
|
||||||
return OrderDetailPanel(
|
|
||||||
order: selected,
|
|
||||||
onViewProduct: widget.onViewProduct,
|
|
||||||
onViewInventory: widget.onViewInventory,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../domain/order.dart';
|
|
||||||
import 'order_status_chip.dart';
|
|
||||||
|
|
||||||
/// A card displaying a summary of an [Order].
|
|
||||||
///
|
|
||||||
/// Shows the order ID, customer name, date, total, item count, and status.
|
|
||||||
/// Highlights when [isSelected] is true.
|
|
||||||
class OrderCard extends StatelessWidget {
|
|
||||||
final Order order;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const OrderCard({super.key, required this.order, this.isSelected = false, this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.all(KcSpacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: KcColors.surface,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected ? KcColors.denimBlue : KcColors.border,
|
|
||||||
width: isSelected ? 2 : 1,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
order.id,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OrderStatusChip(status: order.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.xs),
|
|
||||||
Text(order.customerName, style: Theme.of(context).textTheme.bodyMedium),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'\$${order.total.toStringAsFixed(2)}',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
const SizedBox(width: KcSpacing.sm),
|
|
||||||
Text(
|
|
||||||
'${order.itemCount} item${order.itemCount == 1 ? '' : 's'}',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
_formatDate(order.orderDate),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _formatDate(DateTime date) {
|
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../domain/order.dart';
|
|
||||||
import 'order_status_chip.dart';
|
|
||||||
|
|
||||||
/// A detail panel that shows the full information for the selected [Order].
|
|
||||||
///
|
|
||||||
/// Includes customer info, shipping address, line items table, and order total.
|
|
||||||
class OrderDetailPanel extends StatelessWidget {
|
|
||||||
final Order order;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Products page for a given SKU.
|
|
||||||
final void Function(String sku)? onViewProduct;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Inventory page for a given SKU.
|
|
||||||
final void Function(String sku)? onViewInventory;
|
|
||||||
|
|
||||||
const OrderDetailPanel({
|
|
||||||
super.key,
|
|
||||||
required this.order,
|
|
||||||
this.onViewProduct,
|
|
||||||
this.onViewInventory,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return KcCard(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Header ─────────────────────────────────────────────────
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: Text('Order ${order.id}', style: theme.textTheme.headlineMedium)),
|
|
||||||
const SizedBox(width: KcSpacing.sm),
|
|
||||||
OrderStatusChip(status: order.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Customer info ──────────────────────────────────────────
|
|
||||||
Text('Customer', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
_MetadataRow(label: 'Name', value: order.customerName),
|
|
||||||
_MetadataRow(label: 'Email', value: order.customerEmail),
|
|
||||||
_MetadataRow(label: 'Order Date', value: _formatDate(order.orderDate)),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Shipping address ───────────────────────────────────────
|
|
||||||
Text('Shipping Address', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text(order.shippingAddress, style: theme.textTheme.bodyLarge),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Line items ─────────────────────────────────────────────
|
|
||||||
Text('Items', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
...order.items.map(
|
|
||||||
(item) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(item.productName, style: theme.textTheme.bodyMedium),
|
|
||||||
Text(
|
|
||||||
'SKU: ${item.sku}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
if (onViewProduct != null || onViewInventory != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: KcSpacing.xs),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: KcSpacing.sm,
|
|
||||||
children: [
|
|
||||||
if (onViewProduct != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => onViewProduct!(item.sku),
|
|
||||||
child: Text(
|
|
||||||
'View Product',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: KcColors.denimBlue,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onViewInventory != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => onViewInventory!(item.sku),
|
|
||||||
child: Text(
|
|
||||||
'View Inventory',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: KcColors.denimBlue,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 40,
|
|
||||||
child: Text(
|
|
||||||
'x${item.quantity}',
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
'\$${item.lineTotal.toStringAsFixed(2)}',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: KcSpacing.lg),
|
|
||||||
|
|
||||||
// ── Total ──────────────────────────────────────────────────
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text('Total: ', style: theme.textTheme.titleLarge),
|
|
||||||
Text(
|
|
||||||
'\$${order.total.toStringAsFixed(2)}',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _formatDate(DateTime date) {
|
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MetadataRow extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const _MetadataRow({required this.label, required this.value});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 120,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: KcColors.neutral,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../domain/order_status.dart';
|
|
||||||
|
|
||||||
/// A chip that displays the [OrderStatus] of an order using the
|
|
||||||
/// design-system [KcStatusChip].
|
|
||||||
class OrderStatusChip extends StatelessWidget {
|
|
||||||
final OrderStatus status;
|
|
||||||
|
|
||||||
const OrderStatusChip({super.key, required this.status});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final (label, bg, fg) = _style(status);
|
|
||||||
return KcStatusChip(label: label, background: bg, foreground: fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
static (String, Color, Color) _style(OrderStatus status) {
|
|
||||||
switch (status) {
|
|
||||||
case OrderStatus.pending:
|
|
||||||
return ('Pending', const Color(0xFFFFF8E1), KcColors.warning);
|
|
||||||
case OrderStatus.processing:
|
|
||||||
return ('Processing', const Color(0xFFE3F2FD), KcColors.denimBlue);
|
|
||||||
case OrderStatus.shipped:
|
|
||||||
return ('Shipped', const Color(0xFFE0F7FA), KcColors.deepTeal);
|
|
||||||
case OrderStatus.delivered:
|
|
||||||
return ('Delivered', const Color(0xFFE8F5E9), KcColors.success);
|
|
||||||
case OrderStatus.cancelled:
|
|
||||||
return ('Cancelled', const Color(0xFFFFEBEE), KcColors.danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
# Generated by pub
|
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
|
||||||
packages:
|
|
||||||
async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: async
|
|
||||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.13.1"
|
|
||||||
boolean_selector:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: boolean_selector
|
|
||||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.2"
|
|
||||||
characters:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: characters
|
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.1"
|
|
||||||
clock:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: clock
|
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.2"
|
|
||||||
collection:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: collection
|
|
||||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.19.1"
|
|
||||||
design_system:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "../design_system"
|
|
||||||
relative: true
|
|
||||||
source: path
|
|
||||||
version: "0.0.1"
|
|
||||||
fake_async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: fake_async
|
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.3"
|
|
||||||
flutter:
|
|
||||||
dependency: "direct main"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_lints:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: flutter_lints
|
|
||||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.0.0"
|
|
||||||
flutter_test:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
leak_tracker:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker
|
|
||||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "11.0.2"
|
|
||||||
leak_tracker_flutter_testing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker_flutter_testing
|
|
||||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.10"
|
|
||||||
leak_tracker_testing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker_testing
|
|
||||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.2"
|
|
||||||
lints:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: lints
|
|
||||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.1.0"
|
|
||||||
matcher:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: matcher
|
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.12.19"
|
|
||||||
material_color_utilities:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: material_color_utilities
|
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.13.0"
|
|
||||||
meta:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: meta
|
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.17.0"
|
|
||||||
path:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path
|
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.9.1"
|
|
||||||
sky_engine:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
source_span:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: source_span
|
|
||||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.10.2"
|
|
||||||
stack_trace:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stack_trace
|
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.12.1"
|
|
||||||
stream_channel:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stream_channel
|
|
||||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.4"
|
|
||||||
string_scanner:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: string_scanner
|
|
||||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.1"
|
|
||||||
term_glyph:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: term_glyph
|
|
||||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.2"
|
|
||||||
test_api:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: test_api
|
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.10"
|
|
||||||
vector_math:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vector_math
|
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
vm_service:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vm_service
|
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "15.0.2"
|
|
||||||
sdks:
|
|
||||||
dart: ">=3.11.4 <4.0.0"
|
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
name: feature_orders
|
|
||||||
description: "Order management feature for Kell Creations."
|
|
||||||
version: 0.0.1
|
|
||||||
publish_to: "none"
|
|
||||||
homepage:
|
|
||||||
|
|
||||||
environment:
|
|
||||||
sdk: ^3.11.4
|
|
||||||
flutter: ">=1.17.0"
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
flutter:
|
|
||||||
sdk: flutter
|
|
||||||
design_system:
|
|
||||||
path: ../design_system
|
|
||||||
|
|
||||||
dev_dependencies:
|
|
||||||
flutter_test:
|
|
||||||
sdk: flutter
|
|
||||||
flutter_lints: ^6.0.0
|
|
||||||
|
|
||||||
flutter:
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late FakeOrdersRepository repository;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
repository = FakeOrdersRepository();
|
|
||||||
});
|
|
||||||
|
|
||||||
group('FakeOrdersRepository', () {
|
|
||||||
test('getOrders returns six sample orders', () async {
|
|
||||||
final orders = await repository.getOrders();
|
|
||||||
expect(orders.length, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getOrders returns orders with expected IDs', () async {
|
|
||||||
final orders = await repository.getOrders();
|
|
||||||
final ids = orders.map((o) => o.id).toList();
|
|
||||||
expect(ids, contains('KC-1001'));
|
|
||||||
expect(ids, contains('KC-1002'));
|
|
||||||
expect(ids, contains('KC-1003'));
|
|
||||||
expect(ids, contains('KC-1004'));
|
|
||||||
expect(ids, contains('KC-1005'));
|
|
||||||
expect(ids, contains('KC-1006'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getOrders returns orders with various statuses', () async {
|
|
||||||
final orders = await repository.getOrders();
|
|
||||||
final statuses = orders.map((o) => o.status).toSet();
|
|
||||||
expect(statuses, contains(OrderStatus.pending));
|
|
||||||
expect(statuses, contains(OrderStatus.processing));
|
|
||||||
expect(statuses, contains(OrderStatus.shipped));
|
|
||||||
expect(statuses, contains(OrderStatus.delivered));
|
|
||||||
expect(statuses, contains(OrderStatus.cancelled));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('order totals are computed correctly', () async {
|
|
||||||
final orders = await repository.getOrders();
|
|
||||||
// KC-1001: 2 * 12.99 + 1 * 16.50 = 42.48
|
|
||||||
final order1001 = orders.firstWhere((o) => o.id == 'KC-1001');
|
|
||||||
expect(order1001.total, closeTo(42.48, 0.01));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('order item count is computed correctly', () async {
|
|
||||||
final orders = await repository.getOrders();
|
|
||||||
// KC-1001: 2 + 1 = 3 items
|
|
||||||
final order1001 = orders.firstWhere((o) => o.id == 'KC-1001');
|
|
||||||
expect(order1001.itemCount, 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// This file ensures the barrel export compiles correctly.
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('barrel export exposes Order', () {
|
|
||||||
// Verify the domain model is accessible through the barrel export.
|
|
||||||
final order = Order(
|
|
||||||
id: 'test',
|
|
||||||
customerName: 'Test',
|
|
||||||
customerEmail: 'test@test.com',
|
|
||||||
orderDate: DateTime(2026, 1, 1),
|
|
||||||
status: OrderStatus.pending,
|
|
||||||
items: const [],
|
|
||||||
shippingAddress: '123 Test St',
|
|
||||||
);
|
|
||||||
expect(order.id, 'test');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_orders/src/application/get_orders.dart';
|
|
||||||
import 'package:feature_orders/src/application/orders_controller.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late FakeOrdersRepository repository;
|
|
||||||
late OrdersController controller;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
repository = FakeOrdersRepository();
|
|
||||||
controller = OrdersController(GetOrders(repository));
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() {
|
|
||||||
controller.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
group('OrdersController', () {
|
|
||||||
test('starts with empty state', () {
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.orders, isEmpty);
|
|
||||||
expect(controller.selectedOrder, isNull);
|
|
||||||
expect(controller.activeFilter, isNull);
|
|
||||||
expect(controller.searchQuery, '');
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load populates orders and auto-selects first', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.orders.length, 6);
|
|
||||||
expect(controller.selectedOrder, isNotNull);
|
|
||||||
expect(controller.selectedOrder!.id, 'KC-1001');
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectOrder updates selectedOrder', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final third = controller.orders[2];
|
|
||||||
controller.selectOrder(third);
|
|
||||||
|
|
||||||
expect(controller.selectedOrder!.id, third.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setFilter filters by order status', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setFilter('pending');
|
|
||||||
expect(controller.orders.length, 2);
|
|
||||||
expect(controller.orders.every((o) => o.status == OrderStatus.pending), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setFilter with null shows all orders', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setFilter('cancelled');
|
|
||||||
expect(controller.orders.length, 1);
|
|
||||||
|
|
||||||
controller.setFilter(null);
|
|
||||||
expect(controller.orders.length, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSearchQuery filters by customer name', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('sarah');
|
|
||||||
expect(controller.orders.length, 1);
|
|
||||||
expect(controller.orders.first.customerName, 'Sarah Mitchell');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSearchQuery filters by order ID', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('KC-1003');
|
|
||||||
expect(controller.orders.length, 1);
|
|
||||||
expect(controller.orders.first.id, 'KC-1003');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filter and search combine', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setFilter('pending');
|
|
||||||
controller.setSearchQuery('david');
|
|
||||||
expect(controller.orders.length, 1);
|
|
||||||
expect(controller.orders.first.customerName, 'David Park');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectById selects matching order', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final found = controller.selectById('KC-1004');
|
|
||||||
expect(found, true);
|
|
||||||
expect(controller.selectedOrder!.id, 'KC-1004');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectById returns false for unknown ID', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final found = controller.selectById('UNKNOWN');
|
|
||||||
expect(found, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selection is cleared when filtered out', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
// Select a delivered order.
|
|
||||||
controller.selectById('KC-1001');
|
|
||||||
expect(controller.selectedOrder, isNotNull);
|
|
||||||
|
|
||||||
// Filter to pending — the selected order should be cleared.
|
|
||||||
controller.setFilter('pending');
|
|
||||||
expect(controller.selectedOrder, isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_orders/src/presentation/widgets/order_card.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
final sampleOrder = Order(
|
|
||||||
id: 'KC-9999',
|
|
||||||
customerName: 'Test Customer',
|
|
||||||
customerEmail: 'test@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 1),
|
|
||||||
status: OrderStatus.processing,
|
|
||||||
shippingAddress: '123 Test St, Test City, TS 00000',
|
|
||||||
items: const [
|
|
||||||
OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 2, unitPrice: 10.00),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget buildTestWidget({bool isSelected = false, VoidCallback? onTap}) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: buildKcTheme(),
|
|
||||||
home: Scaffold(
|
|
||||||
body: SizedBox(
|
|
||||||
height: 200,
|
|
||||||
width: 400,
|
|
||||||
child: OrderCard(order: sampleOrder, isSelected: isSelected, onTap: onTap),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
group('OrderCard', () {
|
|
||||||
testWidgets('displays order ID', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget());
|
|
||||||
expect(find.text('KC-9999'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays customer name', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget());
|
|
||||||
expect(find.text('Test Customer'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays total', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget());
|
|
||||||
expect(find.text('\$20.00'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays item count', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget());
|
|
||||||
expect(find.text('2 items'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays status chip', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget());
|
|
||||||
expect(find.text('Processing'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays date', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget());
|
|
||||||
expect(find.text('2026-04-01'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('calls onTap when tapped', (tester) async {
|
|
||||||
var tapped = false;
|
|
||||||
await tester.pumpWidget(buildTestWidget(onTap: () => tapped = true));
|
|
||||||
await tester.tap(find.text('KC-9999'));
|
|
||||||
expect(tapped, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_orders/src/presentation/widgets/order_detail_panel.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
final sampleOrder = Order(
|
|
||||||
id: 'KC-9999',
|
|
||||||
customerName: 'Test Customer',
|
|
||||||
customerEmail: 'test@example.com',
|
|
||||||
orderDate: DateTime(2026, 4, 1),
|
|
||||||
status: OrderStatus.shipped,
|
|
||||||
shippingAddress: '123 Test St, Test City, TS 00000',
|
|
||||||
items: const [
|
|
||||||
OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 2, unitPrice: 10.00),
|
|
||||||
OrderItem(productName: 'Another Product', sku: 'AP-002', quantity: 1, unitPrice: 15.50),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget buildTestWidget(Order order) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: buildKcTheme(),
|
|
||||||
home: Scaffold(
|
|
||||||
body: SingleChildScrollView(child: OrderDetailPanel(order: order)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
group('OrderDetailPanel', () {
|
|
||||||
testWidgets('displays order ID in header', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('Order KC-9999'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays status chip', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('Shipped'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays customer name', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('Test Customer'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays customer email', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('test@example.com'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays shipping address', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('123 Test St, Test City, TS 00000'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays line item product names', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('Test Product'), findsOneWidget);
|
|
||||||
expect(find.text('Another Product'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays line item SKUs', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
expect(find.text('SKU: TP-001'), findsOneWidget);
|
|
||||||
expect(find.text('SKU: AP-002'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays order total', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(sampleOrder));
|
|
||||||
// 2 * 10.00 + 1 * 15.50 = 35.50
|
|
||||||
expect(find.text('\$35.50'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
|
||||||
import 'package:feature_orders/src/presentation/widgets/order_status_chip.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
Widget buildTestWidget(OrderStatus status) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: buildKcTheme(),
|
|
||||||
home: Scaffold(body: OrderStatusChip(status: status)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
group('OrderStatusChip', () {
|
|
||||||
testWidgets('shows Pending label for pending status', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(OrderStatus.pending));
|
|
||||||
expect(find.text('Pending'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows Processing label for processing status', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(OrderStatus.processing));
|
|
||||||
expect(find.text('Processing'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows Shipped label for shipped status', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(OrderStatus.shipped));
|
|
||||||
expect(find.text('Shipped'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows Delivered label for delivered status', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(OrderStatus.delivered));
|
|
||||||
expect(find.text('Delivered'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows Cancelled label for cancelled status', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestWidget(OrderStatus.cancelled));
|
|
||||||
expect(find.text('Cancelled'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
library;
|
/// A Calculator.
|
||||||
|
class Calculator {
|
||||||
export 'src/data/fake_policy_repository.dart';
|
/// Returns [value] plus 1.
|
||||||
export 'src/domain/governance_status.dart';
|
int addOne(int value) => value + 1;
|
||||||
export 'src/domain/policy_check_result.dart';
|
}
|
||||||
export 'src/domain/policy_repository.dart';
|
|
||||||
export 'src/presentation/policy_page.dart';
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import '../domain/policy_check_result.dart';
|
|
||||||
import '../domain/policy_repository.dart';
|
|
||||||
|
|
||||||
/// Use case: retrieve all policy-check results from the repository.
|
|
||||||
class GetPolicyChecks {
|
|
||||||
final PolicyRepository repository;
|
|
||||||
|
|
||||||
GetPolicyChecks(this.repository);
|
|
||||||
|
|
||||||
Future<List<PolicyCheckResult>> call() => repository.getPolicyChecks();
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../domain/policy_check_result.dart';
|
|
||||||
import 'get_policy_checks.dart';
|
|
||||||
|
|
||||||
/// Controller that manages the policy-checks workspace state, including
|
|
||||||
/// filtering by category, free-text search, and check selection.
|
|
||||||
class PolicyController extends ChangeNotifier {
|
|
||||||
final GetPolicyChecks _getPolicyChecks;
|
|
||||||
|
|
||||||
PolicyController(this._getPolicyChecks);
|
|
||||||
|
|
||||||
bool isLoading = false;
|
|
||||||
Object? error;
|
|
||||||
|
|
||||||
/// All checks returned by the repository (unfiltered).
|
|
||||||
List<PolicyCheckResult> _allChecks = [];
|
|
||||||
|
|
||||||
/// The currently visible checks after applying [activeCategory] and [searchQuery].
|
|
||||||
List<PolicyCheckResult> checks = [];
|
|
||||||
|
|
||||||
/// The currently selected check for detail view.
|
|
||||||
PolicyCheckResult? selectedCheck;
|
|
||||||
|
|
||||||
/// The active category filter, or `null` for "all".
|
|
||||||
///
|
|
||||||
/// Matches [PolicyCheckResult.category] values such as
|
|
||||||
/// `'Product Compliance'`, `'Inventory Governance'`, etc.
|
|
||||||
String? activeCategory;
|
|
||||||
|
|
||||||
/// The current free-text search query applied to title / summary.
|
|
||||||
String searchQuery = '';
|
|
||||||
|
|
||||||
/// Loads all policy checks and applies any current filter / search.
|
|
||||||
Future<void> load() async {
|
|
||||||
isLoading = true;
|
|
||||||
error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
_allChecks = await _getPolicyChecks();
|
|
||||||
_applyFilters();
|
|
||||||
// Auto-select the first check if nothing is selected.
|
|
||||||
selectedCheck ??= checks.isNotEmpty ? checks.first : null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the category filter and recomputes the visible list.
|
|
||||||
void setCategory(String? category) {
|
|
||||||
activeCategory = category;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the search query and recomputes the visible list.
|
|
||||||
void setSearchQuery(String query) {
|
|
||||||
searchQuery = query;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects a policy check for detail view.
|
|
||||||
void selectCheck(PolicyCheckResult check) {
|
|
||||||
selectedCheck = check;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to select a check by ID. Returns `true` if found.
|
|
||||||
bool selectById(String id) {
|
|
||||||
final match = _allChecks.where((c) => c.id == id).firstOrNull;
|
|
||||||
if (match != null) {
|
|
||||||
selectedCheck = match;
|
|
||||||
notifyListeners();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void _applyFilters() {
|
|
||||||
var result = _allChecks;
|
|
||||||
|
|
||||||
// Category filter
|
|
||||||
if (activeCategory != null && activeCategory!.isNotEmpty) {
|
|
||||||
result = result.where((c) => c.category == activeCategory).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free-text search on title and summary
|
|
||||||
if (searchQuery.isNotEmpty) {
|
|
||||||
final q = searchQuery.toLowerCase();
|
|
||||||
result = result.where((c) {
|
|
||||||
return c.title.toLowerCase().contains(q) || c.summary.toLowerCase().contains(q);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
checks = result;
|
|
||||||
|
|
||||||
// Keep selection valid; clear if the selected check is no longer visible.
|
|
||||||
if (selectedCheck != null && !checks.any((c) => c.id == selectedCheck!.id)) {
|
|
||||||
selectedCheck = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import '../domain/governance_status.dart';
|
|
||||||
import '../domain/policy_check_result.dart';
|
|
||||||
import '../domain/policy_repository.dart';
|
|
||||||
|
|
||||||
/// In-memory fake that returns sample readiness and governance checks
|
|
||||||
/// derived from Kell Creations products, inventory, and orders.
|
|
||||||
class FakePolicyRepository implements PolicyRepository {
|
|
||||||
@override
|
|
||||||
Future<List<PolicyCheckResult>> getPolicyChecks() async {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
final now = DateTime(2026, 4, 4);
|
|
||||||
|
|
||||||
return [
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-001',
|
|
||||||
title: 'Product Safety Labeling',
|
|
||||||
category: 'Product Compliance',
|
|
||||||
status: GovernanceStatus.compliant,
|
|
||||||
summary: 'All active products carry required safety labels.',
|
|
||||||
detail:
|
|
||||||
'Floral Bowl Cozy, Citrus Coaster Set, Ocean Nightlight, Fabric '
|
|
||||||
'Jar Gripper, and Skillet Handle Sleeve have been reviewed and '
|
|
||||||
'carry the appropriate consumer-safety labels per CPSC guidelines. '
|
|
||||||
'Sublimated Slate Coaster is still in draft and will be evaluated '
|
|
||||||
'before listing.',
|
|
||||||
lastEvaluated: now.subtract(const Duration(days: 2)),
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-002',
|
|
||||||
title: 'Inventory Accuracy Audit',
|
|
||||||
category: 'Inventory Governance',
|
|
||||||
status: GovernanceStatus.needsReview,
|
|
||||||
summary: 'Two SKUs show zero on-hand quantity and need recount.',
|
|
||||||
detail:
|
|
||||||
'Ocean Nightlight (NL-OCN-003) and Sublimated Slate Coaster '
|
|
||||||
'(SC-SUB-006) report zero quantity on hand. A physical recount '
|
|
||||||
'should be scheduled to confirm whether stock-outs are accurate '
|
|
||||||
'or the result of a data-entry discrepancy.',
|
|
||||||
lastEvaluated: now.subtract(const Duration(days: 1)),
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-003',
|
|
||||||
title: 'Order Fulfillment SLA',
|
|
||||||
category: 'Order Operations',
|
|
||||||
status: GovernanceStatus.compliant,
|
|
||||||
summary: 'All orders shipped within the 3-business-day SLA.',
|
|
||||||
detail:
|
|
||||||
'Review of recent orders (KC-1001 through KC-1006) confirms that '
|
|
||||||
'processing-to-ship times remain within the published 3-business-day '
|
|
||||||
'service-level agreement. No SLA breaches detected.',
|
|
||||||
lastEvaluated: now,
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-004',
|
|
||||||
title: 'Pricing Consistency',
|
|
||||||
category: 'Product Compliance',
|
|
||||||
status: GovernanceStatus.nonCompliant,
|
|
||||||
summary: 'Citrus Coaster Set price differs between store and inventory.',
|
|
||||||
detail:
|
|
||||||
'The Citrus Coaster Set (CS-CIT-002) is listed at \$16.50 in '
|
|
||||||
'inventory but the WordPress storefront shows \$15.99. Prices must '
|
|
||||||
'be reconciled before the next sales cycle to avoid consumer '
|
|
||||||
'protection issues.',
|
|
||||||
lastEvaluated: now,
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-005',
|
|
||||||
title: 'Return & Refund Policy Published',
|
|
||||||
category: 'Customer Policy',
|
|
||||||
status: GovernanceStatus.compliant,
|
|
||||||
summary: 'Return and refund policy is published and up to date.',
|
|
||||||
detail:
|
|
||||||
'The 30-day return and refund policy is published on the Kell '
|
|
||||||
'Creations storefront and was last reviewed on 2026-03-15. No '
|
|
||||||
'changes are required at this time.',
|
|
||||||
lastEvaluated: now.subtract(const Duration(days: 20)),
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-006',
|
|
||||||
title: 'Low-Stock Reorder Trigger',
|
|
||||||
category: 'Inventory Governance',
|
|
||||||
status: GovernanceStatus.needsReview,
|
|
||||||
summary: 'Two products are below the reorder threshold.',
|
|
||||||
detail:
|
|
||||||
'Citrus Coaster Set (7 units) and Skillet Handle Sleeve (5 units) '
|
|
||||||
'are below the 10-unit reorder point. Purchase orders should be '
|
|
||||||
'raised to avoid stock-outs that could delay order fulfillment.',
|
|
||||||
lastEvaluated: now.subtract(const Duration(days: 1)),
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-007',
|
|
||||||
title: 'Sales Tax Configuration',
|
|
||||||
category: 'Finance & Tax',
|
|
||||||
status: GovernanceStatus.notApplicable,
|
|
||||||
summary: 'Sales tax collection is not yet enabled.',
|
|
||||||
detail:
|
|
||||||
'Kell Creations has not yet reached the sales-tax nexus threshold '
|
|
||||||
'in any state. This check will become applicable once annual '
|
|
||||||
'revenue exceeds the relevant economic-nexus limits.',
|
|
||||||
lastEvaluated: now.subtract(const Duration(days: 30)),
|
|
||||||
),
|
|
||||||
PolicyCheckResult(
|
|
||||||
id: 'POL-008',
|
|
||||||
title: 'Cancelled Order Review',
|
|
||||||
category: 'Order Operations',
|
|
||||||
status: GovernanceStatus.nonCompliant,
|
|
||||||
summary: 'A cancelled order lacks a documented reason.',
|
|
||||||
detail:
|
|
||||||
'Order KC-1006 was cancelled but no cancellation reason was '
|
|
||||||
'recorded in the system. Store policy requires a documented reason '
|
|
||||||
'for every cancellation to support dispute resolution and trend '
|
|
||||||
'analysis.',
|
|
||||||
lastEvaluated: now,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
/// The governance readiness status of a policy check.
|
|
||||||
enum GovernanceStatus { compliant, nonCompliant, needsReview, notApplicable }
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import 'governance_status.dart';
|
|
||||||
|
|
||||||
/// A single policy-check result describing whether a governance rule is met.
|
|
||||||
class PolicyCheckResult {
|
|
||||||
final String id;
|
|
||||||
final String title;
|
|
||||||
final String category;
|
|
||||||
final GovernanceStatus status;
|
|
||||||
final String summary;
|
|
||||||
final String detail;
|
|
||||||
final DateTime lastEvaluated;
|
|
||||||
|
|
||||||
const PolicyCheckResult({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.category,
|
|
||||||
required this.status,
|
|
||||||
required this.summary,
|
|
||||||
required this.detail,
|
|
||||||
required this.lastEvaluated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import 'policy_check_result.dart';
|
|
||||||
|
|
||||||
/// Contract for fetching policy-check results.
|
|
||||||
abstract class PolicyRepository {
|
|
||||||
/// Returns all policy-check results.
|
|
||||||
Future<List<PolicyCheckResult>> getPolicyChecks();
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../application/get_policy_checks.dart';
|
|
||||||
import '../application/policy_controller.dart';
|
|
||||||
import '../domain/policy_repository.dart';
|
|
||||||
import 'widgets/policy_check_card.dart';
|
|
||||||
import 'widgets/policy_detail_panel.dart';
|
|
||||||
|
|
||||||
/// The main Policy page.
|
|
||||||
///
|
|
||||||
/// Displays a list of policy checks on the left and a detail panel on the
|
|
||||||
/// right. Users can select a check to view its full details.
|
|
||||||
class PolicyPage extends StatefulWidget {
|
|
||||||
final PolicyRepository repository;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to a related operational page based on
|
|
||||||
/// the policy check's category. Provided by the app layer.
|
|
||||||
final void Function(String category)? onViewRelatedPage;
|
|
||||||
|
|
||||||
/// Optional initial category filter to apply on first load
|
|
||||||
/// (e.g. `'Product Compliance'`).
|
|
||||||
final String? initialCategory;
|
|
||||||
|
|
||||||
/// Optional initial search query to apply on first load.
|
|
||||||
final String? initialQuery;
|
|
||||||
|
|
||||||
/// Optional check ID to pre-select on first load (from a navigation handoff).
|
|
||||||
final String? initialSelectedId;
|
|
||||||
|
|
||||||
const PolicyPage({
|
|
||||||
super.key,
|
|
||||||
required this.repository,
|
|
||||||
this.onViewRelatedPage,
|
|
||||||
this.initialCategory,
|
|
||||||
this.initialQuery,
|
|
||||||
this.initialSelectedId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<PolicyPage> createState() => _PolicyPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PolicyPageState extends State<PolicyPage> {
|
|
||||||
late final PolicyController controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = PolicyController(GetPolicyChecks(widget.repository));
|
|
||||||
|
|
||||||
// Apply any initial category / query before loading.
|
|
||||||
if (widget.initialCategory != null) {
|
|
||||||
controller.activeCategory = widget.initialCategory;
|
|
||||||
}
|
|
||||||
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
|
||||||
controller.searchQuery = widget.initialQuery!;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.load().then((_) {
|
|
||||||
// After data is loaded, try to pre-select by ID if requested.
|
|
||||||
if (widget.initialSelectedId != null) {
|
|
||||||
controller.selectById(widget.initialSelectedId!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
if (controller.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller.error != null) {
|
|
||||||
return const Center(child: Text('Failed to load policy checks.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
// On narrow screens show only the list; on wide screens show
|
|
||||||
// a master-detail layout.
|
|
||||||
if (constraints.maxWidth < 800) {
|
|
||||||
return _buildCheckList();
|
|
||||||
}
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(width: 400, child: _buildCheckList()),
|
|
||||||
const SizedBox(width: KcSpacing.md),
|
|
||||||
Expanded(child: _buildDetail()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCheckList() {
|
|
||||||
return ListView.separated(
|
|
||||||
itemCount: controller.checks.length,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final check = controller.checks[index];
|
|
||||||
return SizedBox(
|
|
||||||
height: 160,
|
|
||||||
child: PolicyCheckCard(
|
|
||||||
check: check,
|
|
||||||
isSelected: check.id == controller.selectedCheck?.id,
|
|
||||||
onTap: () => controller.selectCheck(check),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetail() {
|
|
||||||
final selected = controller.selectedCheck;
|
|
||||||
if (selected == null) {
|
|
||||||
return const Center(child: Text('Select a policy check to view details'));
|
|
||||||
}
|
|
||||||
return PolicyDetailPanel(check: selected, onViewRelatedPage: widget.onViewRelatedPage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../domain/governance_status.dart';
|
|
||||||
|
|
||||||
/// A chip that displays the [GovernanceStatus] of a policy check using the
|
|
||||||
/// design-system [KcStatusChip].
|
|
||||||
class GovernanceStatusChip extends StatelessWidget {
|
|
||||||
final GovernanceStatus status;
|
|
||||||
|
|
||||||
const GovernanceStatusChip({super.key, required this.status});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final (label, bg, fg) = _style(status);
|
|
||||||
return KcStatusChip(label: label, background: bg, foreground: fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
static (String, Color, Color) _style(GovernanceStatus status) {
|
|
||||||
switch (status) {
|
|
||||||
case GovernanceStatus.compliant:
|
|
||||||
return ('Compliant', const Color(0xFFE8F5E9), KcColors.success);
|
|
||||||
case GovernanceStatus.nonCompliant:
|
|
||||||
return ('Non-compliant', const Color(0xFFFFEBEE), KcColors.danger);
|
|
||||||
case GovernanceStatus.needsReview:
|
|
||||||
return ('Needs review', const Color(0xFFFFF8E1), KcColors.warning);
|
|
||||||
case GovernanceStatus.notApplicable:
|
|
||||||
return ('N/A', const Color(0xFFECEFF1), KcColors.neutral);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../domain/policy_check_result.dart';
|
|
||||||
import 'governance_status_chip.dart';
|
|
||||||
|
|
||||||
/// A card displaying a summary of a [PolicyCheckResult].
|
|
||||||
///
|
|
||||||
/// Shows the check ID, title, category, status chip, and summary.
|
|
||||||
/// Highlights when [isSelected] is true.
|
|
||||||
class PolicyCheckCard extends StatelessWidget {
|
|
||||||
final PolicyCheckResult check;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const PolicyCheckCard({super.key, required this.check, this.isSelected = false, this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.all(KcSpacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: KcColors.surface,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected ? KcColors.denimBlue : KcColors.border,
|
|
||||||
width: isSelected ? 2 : 1,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
check.title,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GovernanceStatusChip(status: check.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.xs),
|
|
||||||
Text(
|
|
||||||
check.category,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(color: KcColors.neutral, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text(
|
|
||||||
check.summary,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
check.id,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../domain/policy_check_result.dart';
|
|
||||||
import 'governance_status_chip.dart';
|
|
||||||
|
|
||||||
/// A detail panel that shows the full information for the selected
|
|
||||||
/// [PolicyCheckResult].
|
|
||||||
///
|
|
||||||
/// Includes the title, status, category, summary, full detail text,
|
|
||||||
/// and last-evaluated date.
|
|
||||||
class PolicyDetailPanel extends StatelessWidget {
|
|
||||||
final PolicyCheckResult check;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the related operational page
|
|
||||||
/// based on the check's category.
|
|
||||||
final void Function(String category)? onViewRelatedPage;
|
|
||||||
|
|
||||||
const PolicyDetailPanel({super.key, required this.check, this.onViewRelatedPage});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return KcCard(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Header ─────────────────────────────────────────────────
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: Text(check.title, style: theme.textTheme.headlineMedium)),
|
|
||||||
const SizedBox(width: KcSpacing.sm),
|
|
||||||
GovernanceStatusChip(status: check.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Metadata ───────────────────────────────────────────────
|
|
||||||
_MetadataRow(label: 'Check ID', value: check.id),
|
|
||||||
_MetadataRow(label: 'Category', value: check.category),
|
|
||||||
_MetadataRow(label: 'Last Evaluated', value: _formatDate(check.lastEvaluated)),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Summary ────────────────────────────────────────────────
|
|
||||||
Text('Summary', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text(check.summary, style: theme.textTheme.bodyLarge),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Detail ─────────────────────────────────────────────────
|
|
||||||
Text('Detail', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
Text(check.detail, style: theme.textTheme.bodyMedium),
|
|
||||||
|
|
||||||
// ── Related page link ──────────────────────────────────────
|
|
||||||
if (onViewRelatedPage != null) ...[
|
|
||||||
const SizedBox(height: KcSpacing.lg),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => onViewRelatedPage!(check.category),
|
|
||||||
child: Text(
|
|
||||||
'Go to ${check.category} →',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: KcColors.denimBlue,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _formatDate(DateTime date) {
|
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MetadataRow extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const _MetadataRow({required this.label, required this.value});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 130,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: KcColors.neutral,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
name: feature_policy
|
name: feature_policy
|
||||||
description: "Policy governance checks for Kell Creations."
|
description: "A new Flutter package project."
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
publish_to: "none"
|
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -11,12 +10,45 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
design_system:
|
|
||||||
path: ../design_system
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
flutter:
|
flutter:
|
||||||
|
|
||||||
|
# To add assets to your package, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
#
|
||||||
|
# For details regarding assets in packages, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
#
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# To add custom fonts to your package, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts in packages, see
|
||||||
|
# https://flutter.dev/to/font-from-package
|
||||||
|
|
|
||||||
|
|
@ -1,177 +1,12 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:feature_policy/src/application/get_policy_checks.dart';
|
|
||||||
import 'package:feature_policy/src/application/policy_controller.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('GovernanceStatus', () {
|
test('adds one to input values', () {
|
||||||
test('has four values', () {
|
final calculator = Calculator();
|
||||||
expect(GovernanceStatus.values.length, 4);
|
expect(calculator.addOne(2), 3);
|
||||||
});
|
expect(calculator.addOne(-7), -6);
|
||||||
|
expect(calculator.addOne(0), 1);
|
||||||
test('contains expected statuses', () {
|
|
||||||
expect(GovernanceStatus.values, contains(GovernanceStatus.compliant));
|
|
||||||
expect(GovernanceStatus.values, contains(GovernanceStatus.nonCompliant));
|
|
||||||
expect(GovernanceStatus.values, contains(GovernanceStatus.needsReview));
|
|
||||||
expect(GovernanceStatus.values, contains(GovernanceStatus.notApplicable));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('FakePolicyRepository', () {
|
|
||||||
late FakePolicyRepository repository;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
repository = FakePolicyRepository();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getPolicyChecks returns eight sample checks', () async {
|
|
||||||
final checks = await repository.getPolicyChecks();
|
|
||||||
expect(checks.length, 8);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getPolicyChecks returns checks with expected IDs', () async {
|
|
||||||
final checks = await repository.getPolicyChecks();
|
|
||||||
final ids = checks.map((c) => c.id).toList();
|
|
||||||
expect(ids, contains('POL-001'));
|
|
||||||
expect(ids, contains('POL-002'));
|
|
||||||
expect(ids, contains('POL-003'));
|
|
||||||
expect(ids, contains('POL-004'));
|
|
||||||
expect(ids, contains('POL-005'));
|
|
||||||
expect(ids, contains('POL-006'));
|
|
||||||
expect(ids, contains('POL-007'));
|
|
||||||
expect(ids, contains('POL-008'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getPolicyChecks returns checks with various statuses', () async {
|
|
||||||
final checks = await repository.getPolicyChecks();
|
|
||||||
final statuses = checks.map((c) => c.status).toSet();
|
|
||||||
expect(statuses, contains(GovernanceStatus.compliant));
|
|
||||||
expect(statuses, contains(GovernanceStatus.nonCompliant));
|
|
||||||
expect(statuses, contains(GovernanceStatus.needsReview));
|
|
||||||
expect(statuses, contains(GovernanceStatus.notApplicable));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getPolicyChecks returns checks with various categories', () async {
|
|
||||||
final checks = await repository.getPolicyChecks();
|
|
||||||
final categories = checks.map((c) => c.category).toSet();
|
|
||||||
expect(categories, contains('Product Compliance'));
|
|
||||||
expect(categories, contains('Inventory Governance'));
|
|
||||||
expect(categories, contains('Order Operations'));
|
|
||||||
expect(categories, contains('Customer Policy'));
|
|
||||||
expect(categories, contains('Finance & Tax'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('PolicyController', () {
|
|
||||||
late FakePolicyRepository repository;
|
|
||||||
late PolicyController controller;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
repository = FakePolicyRepository();
|
|
||||||
controller = PolicyController(GetPolicyChecks(repository));
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() {
|
|
||||||
controller.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starts with empty state', () {
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.checks, isEmpty);
|
|
||||||
expect(controller.selectedCheck, isNull);
|
|
||||||
expect(controller.activeCategory, isNull);
|
|
||||||
expect(controller.searchQuery, '');
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load populates checks and auto-selects first', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
expect(controller.isLoading, false);
|
|
||||||
expect(controller.checks.length, 8);
|
|
||||||
expect(controller.selectedCheck, isNotNull);
|
|
||||||
expect(controller.selectedCheck!.id, 'POL-001');
|
|
||||||
expect(controller.error, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectCheck updates selectedCheck', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final third = controller.checks[2];
|
|
||||||
controller.selectCheck(third);
|
|
||||||
|
|
||||||
expect(controller.selectedCheck!.id, third.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setCategory filters by category', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setCategory('Product Compliance');
|
|
||||||
expect(controller.checks.length, 2);
|
|
||||||
expect(controller.checks.every((c) => c.category == 'Product Compliance'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setCategory with null shows all checks', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setCategory('Order Operations');
|
|
||||||
expect(controller.checks.length, 2);
|
|
||||||
|
|
||||||
controller.setCategory(null);
|
|
||||||
expect(controller.checks.length, 8);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSearchQuery filters by title', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('pricing');
|
|
||||||
expect(controller.checks.length, 1);
|
|
||||||
expect(controller.checks.first.title, 'Pricing Consistency');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSearchQuery filters by summary', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setSearchQuery('reorder threshold');
|
|
||||||
expect(controller.checks.length, 1);
|
|
||||||
expect(controller.checks.first.id, 'POL-006');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('category and search combine', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
controller.setCategory('Inventory Governance');
|
|
||||||
controller.setSearchQuery('accuracy');
|
|
||||||
expect(controller.checks.length, 1);
|
|
||||||
expect(controller.checks.first.id, 'POL-002');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectById selects matching check', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final found = controller.selectById('POL-005');
|
|
||||||
expect(found, true);
|
|
||||||
expect(controller.selectedCheck!.id, 'POL-005');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selectById returns false for unknown ID', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
final found = controller.selectById('UNKNOWN');
|
|
||||||
expect(found, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selection is cleared when filtered out', () async {
|
|
||||||
await controller.load();
|
|
||||||
|
|
||||||
// Select a Product Compliance check.
|
|
||||||
controller.selectById('POL-001');
|
|
||||||
expect(controller.selectedCheck, isNotNull);
|
|
||||||
|
|
||||||
// Filter to Order Operations — the selected check should be cleared.
|
|
||||||
controller.setCategory('Order Operations');
|
|
||||||
expect(controller.selectedCheck, isNull);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
library;
|
/// A Calculator.
|
||||||
|
class Calculator {
|
||||||
export 'src/data/fake_product_publishing_repository.dart';
|
/// Returns [value] plus 1.
|
||||||
export 'src/data/woo_commerce_api_client.dart';
|
int addOne(int value) => value + 1;
|
||||||
export 'src/data/wordpress_product_mapper.dart';
|
}
|
||||||
export 'src/data/wordpress_product_publishing_repository.dart';
|
|
||||||
export 'src/domain/product_draft.dart';
|
|
||||||
export 'src/domain/product_publishing_repository.dart';
|
|
||||||
export 'src/domain/publish_status.dart';
|
|
||||||
export 'src/presentation/product_publishing_page.dart';
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import '../domain/product_draft.dart';
|
|
||||||
import '../domain/product_publishing_repository.dart';
|
|
||||||
|
|
||||||
/// Use case: retrieve all product drafts from the repository.
|
|
||||||
class GetProductDrafts {
|
|
||||||
final ProductPublishingRepository repository;
|
|
||||||
|
|
||||||
GetProductDrafts(this.repository);
|
|
||||||
|
|
||||||
Future<List<ProductDraft>> call() => repository.getProductDrafts();
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../domain/product_draft.dart';
|
|
||||||
import '../domain/publish_status.dart';
|
|
||||||
import 'get_product_drafts.dart';
|
|
||||||
import 'publish_product.dart';
|
|
||||||
|
|
||||||
/// Controller that manages the product publishing workspace state, including
|
|
||||||
/// filtering by publish status, free-text search, and draft selection.
|
|
||||||
class ProductPublishingController extends ChangeNotifier {
|
|
||||||
final GetProductDrafts _getProductDrafts;
|
|
||||||
final PublishProduct _publishProduct;
|
|
||||||
|
|
||||||
ProductPublishingController(this._getProductDrafts, this._publishProduct);
|
|
||||||
|
|
||||||
bool isLoading = false;
|
|
||||||
Object? error;
|
|
||||||
|
|
||||||
/// All drafts returned by the repository (unfiltered).
|
|
||||||
List<ProductDraft> _allDrafts = [];
|
|
||||||
|
|
||||||
/// The currently visible drafts after applying [activeFilter] and [searchQuery].
|
|
||||||
List<ProductDraft> drafts = [];
|
|
||||||
|
|
||||||
/// The currently selected draft for preview.
|
|
||||||
ProductDraft? selectedDraft;
|
|
||||||
|
|
||||||
/// The active status filter label, or `null` for "all".
|
|
||||||
///
|
|
||||||
/// Recognised values: `'draft'`, `'pendingReview'`, `'published'`, `'unpublished'`.
|
|
||||||
String? activeFilter;
|
|
||||||
|
|
||||||
/// The current free-text search query applied to name / SKU.
|
|
||||||
String searchQuery = '';
|
|
||||||
|
|
||||||
/// Loads all product drafts and applies any current filter / search.
|
|
||||||
Future<void> load() async {
|
|
||||||
isLoading = true;
|
|
||||||
error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
_allDrafts = await _getProductDrafts();
|
|
||||||
_applyFilters();
|
|
||||||
// Auto-select the first draft if nothing is selected.
|
|
||||||
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the status filter and recomputes the visible list.
|
|
||||||
void setFilter(String? filter) {
|
|
||||||
activeFilter = filter;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the search query and recomputes the visible list.
|
|
||||||
void setSearchQuery(String query) {
|
|
||||||
searchQuery = query;
|
|
||||||
_applyFilters();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects a draft for preview.
|
|
||||||
void selectDraft(ProductDraft draft) {
|
|
||||||
selectedDraft = draft;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to select a draft by SKU. Returns `true` if found.
|
|
||||||
bool selectBySku(String sku) {
|
|
||||||
final match = _allDrafts.where((d) => d.sku == sku).firstOrNull;
|
|
||||||
if (match != null) {
|
|
||||||
selectedDraft = match;
|
|
||||||
notifyListeners();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publishes the draft with the given [id] and reloads the list.
|
|
||||||
Future<void> publish(String id) async {
|
|
||||||
try {
|
|
||||||
await _publishProduct(id);
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void _applyFilters() {
|
|
||||||
var result = _allDrafts;
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
final status = _parseStatus(activeFilter);
|
|
||||||
if (status != null) {
|
|
||||||
result = result.where((d) => d.status == status).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free-text search on name and SKU
|
|
||||||
if (searchQuery.isNotEmpty) {
|
|
||||||
final q = searchQuery.toLowerCase();
|
|
||||||
result = result.where((d) {
|
|
||||||
return d.name.toLowerCase().contains(q) || d.sku.toLowerCase().contains(q);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
drafts = result;
|
|
||||||
|
|
||||||
// Keep selection valid; clear if the selected draft is no longer visible.
|
|
||||||
if (selectedDraft != null && !drafts.any((d) => d.id == selectedDraft!.id)) {
|
|
||||||
selectedDraft = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static PublishStatus? _parseStatus(String? filter) {
|
|
||||||
if (filter == null) return null;
|
|
||||||
switch (filter) {
|
|
||||||
case 'draft':
|
|
||||||
return PublishStatus.draft;
|
|
||||||
case 'pendingReview':
|
|
||||||
return PublishStatus.pendingReview;
|
|
||||||
case 'published':
|
|
||||||
return PublishStatus.published;
|
|
||||||
case 'unpublished':
|
|
||||||
return PublishStatus.unpublished;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import '../domain/product_draft.dart';
|
|
||||||
import '../domain/product_publishing_repository.dart';
|
|
||||||
|
|
||||||
/// Use case: publish a single product draft by its [id].
|
|
||||||
class PublishProduct {
|
|
||||||
final ProductPublishingRepository repository;
|
|
||||||
|
|
||||||
PublishProduct(this.repository);
|
|
||||||
|
|
||||||
Future<ProductDraft> call(String id) => repository.publishDraft(id);
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import '../domain/product_draft.dart';
|
|
||||||
import '../domain/product_publishing_repository.dart';
|
|
||||||
import '../domain/publish_status.dart';
|
|
||||||
|
|
||||||
/// Stubbed implementation of [ProductPublishingRepository] with sample
|
|
||||||
/// Kell Creations products. No real WordPress/WooCommerce API calls are made.
|
|
||||||
class FakeProductPublishingRepository implements ProductPublishingRepository {
|
|
||||||
final List<ProductDraft> _drafts = [
|
|
||||||
ProductDraft(
|
|
||||||
id: '1',
|
|
||||||
name: 'Floral Bowl Cozy',
|
|
||||||
description:
|
|
||||||
'A beautifully crafted fabric bowl cozy with a vibrant floral pattern. '
|
|
||||||
'Microwave-safe and perfect for keeping dishes warm at the table.',
|
|
||||||
price: 12.99,
|
|
||||||
sku: 'BC-FLR-001',
|
|
||||||
category: 'Bowl Cozies',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.published,
|
|
||||||
lastModified: DateTime(2026, 3, 28),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '2',
|
|
||||||
name: 'Citrus Coaster Set',
|
|
||||||
description:
|
|
||||||
'Set of four sublimated coasters featuring bright citrus designs. '
|
|
||||||
'Heat-resistant cork backing protects surfaces.',
|
|
||||||
price: 16.50,
|
|
||||||
sku: 'CS-CIT-002',
|
|
||||||
category: 'Coasters',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.published,
|
|
||||||
lastModified: DateTime(2026, 3, 25),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '3',
|
|
||||||
name: 'Ocean Nightlight',
|
|
||||||
description:
|
|
||||||
'Sublimated ceramic nightlight with a calming ocean wave design. '
|
|
||||||
'Includes LED bulb and plugs into any standard outlet.',
|
|
||||||
price: 19.99,
|
|
||||||
sku: 'NL-OCN-003',
|
|
||||||
category: 'Nightlights',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.pendingReview,
|
|
||||||
lastModified: DateTime(2026, 4, 1),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '4',
|
|
||||||
name: 'Fabric Jar Gripper',
|
|
||||||
description:
|
|
||||||
'Non-slip fabric jar gripper with a fun patterned design. '
|
|
||||||
'Opens even the tightest lids with ease.',
|
|
||||||
price: 8.50,
|
|
||||||
sku: 'JG-BLU-004',
|
|
||||||
category: 'Kitchen Accessories',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026, 4, 2),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '5',
|
|
||||||
name: 'Skillet Handle Sleeve',
|
|
||||||
description:
|
|
||||||
'Quilted fabric sleeve that slips over hot skillet handles. '
|
|
||||||
'Available in multiple patterns to match your kitchen décor.',
|
|
||||||
price: 10.99,
|
|
||||||
sku: 'SH-SUN-005',
|
|
||||||
category: 'Kitchen Accessories',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.draft,
|
|
||||||
lastModified: DateTime(2026, 4, 3),
|
|
||||||
),
|
|
||||||
ProductDraft(
|
|
||||||
id: '6',
|
|
||||||
name: 'Sublimated Slate Coaster',
|
|
||||||
description:
|
|
||||||
'Natural slate coaster with a full-color sublimated image. '
|
|
||||||
'Felt feet protect furniture. Makes a great personalized gift.',
|
|
||||||
price: 14.99,
|
|
||||||
sku: 'SC-SUB-006',
|
|
||||||
category: 'Coasters',
|
|
||||||
imageUrl: '',
|
|
||||||
status: PublishStatus.unpublished,
|
|
||||||
lastModified: DateTime(2026, 3, 20),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductDraft>> getProductDrafts() async {
|
|
||||||
// Simulate network latency.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
||||||
return List.unmodifiable(_drafts);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> publishDraft(String id) async {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
final index = _drafts.indexWhere((d) => d.id == id);
|
|
||||||
if (index == -1) {
|
|
||||||
throw StateError('Draft with id $id not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
final original = _drafts[index];
|
|
||||||
final updated = ProductDraft(
|
|
||||||
id: original.id,
|
|
||||||
name: original.name,
|
|
||||||
description: original.description,
|
|
||||||
price: original.price,
|
|
||||||
sku: original.sku,
|
|
||||||
category: original.category,
|
|
||||||
imageUrl: original.imageUrl,
|
|
||||||
status: PublishStatus.published,
|
|
||||||
lastModified: DateTime.now(),
|
|
||||||
);
|
|
||||||
_drafts[index] = updated;
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
/// Lightweight HTTP client for the WooCommerce REST API v3.
|
|
||||||
///
|
|
||||||
/// Handles authentication (Basic Auth over HTTPS) and pagination.
|
|
||||||
/// Only read-only product listing is implemented for now.
|
|
||||||
class WooCommerceApiClient {
|
|
||||||
/// Base URL of the WordPress site (e.g. `https://store.kellcreations.com`).
|
|
||||||
final String siteUrl;
|
|
||||||
|
|
||||||
/// WooCommerce REST API consumer key.
|
|
||||||
final String consumerKey;
|
|
||||||
|
|
||||||
/// WooCommerce REST API consumer secret.
|
|
||||||
final String consumerSecret;
|
|
||||||
|
|
||||||
/// Optional [http.Client] for testing / injection.
|
|
||||||
final http.Client _httpClient;
|
|
||||||
|
|
||||||
WooCommerceApiClient({
|
|
||||||
required this.siteUrl,
|
|
||||||
required this.consumerKey,
|
|
||||||
required this.consumerSecret,
|
|
||||||
http.Client? httpClient,
|
|
||||||
}) : _httpClient = httpClient ?? http.Client();
|
|
||||||
|
|
||||||
/// The base endpoint for WooCommerce REST API v3.
|
|
||||||
String get _baseEndpoint => '$siteUrl/wp-json/wc/v3';
|
|
||||||
|
|
||||||
/// Fetches a paginated list of products from WooCommerce.
|
|
||||||
///
|
|
||||||
/// [page] is 1-based. [perPage] defaults to 100 (WooCommerce max).
|
|
||||||
/// Returns the raw JSON list of product maps.
|
|
||||||
Future<List<Map<String, dynamic>>> getProducts({int page = 1, int perPage = 100}) async {
|
|
||||||
final uri = Uri.parse(
|
|
||||||
'$_baseEndpoint/products',
|
|
||||||
).replace(queryParameters: {'page': page.toString(), 'per_page': perPage.toString()});
|
|
||||||
|
|
||||||
final response = await _httpClient.get(uri, headers: _authHeaders);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw WooCommerceApiException(
|
|
||||||
statusCode: response.statusCode,
|
|
||||||
message: 'Failed to fetch products: ${response.reasonPhrase}',
|
|
||||||
body: response.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(response.body);
|
|
||||||
if (decoded is! List) {
|
|
||||||
throw WooCommerceApiException(
|
|
||||||
statusCode: response.statusCode,
|
|
||||||
message: 'Unexpected response format: expected a JSON array',
|
|
||||||
body: response.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoded.cast<Map<String, dynamic>>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches all products by paginating through the WooCommerce API.
|
|
||||||
///
|
|
||||||
/// Keeps requesting pages until a page returns fewer items than [perPage].
|
|
||||||
Future<List<Map<String, dynamic>>> getAllProducts({int perPage = 100}) async {
|
|
||||||
final allProducts = <Map<String, dynamic>>[];
|
|
||||||
var page = 1;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
final batch = await getProducts(page: page, perPage: perPage);
|
|
||||||
allProducts.addAll(batch);
|
|
||||||
|
|
||||||
if (batch.length < perPage) break;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return allProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Basic Auth headers for WooCommerce REST API.
|
|
||||||
Map<String, String> get _authHeaders {
|
|
||||||
final credentials = base64Encode(utf8.encode('$consumerKey:$consumerSecret'));
|
|
||||||
return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Releases the underlying HTTP client resources.
|
|
||||||
void dispose() {
|
|
||||||
_httpClient.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exception thrown when the WooCommerce API returns an error.
|
|
||||||
class WooCommerceApiException implements Exception {
|
|
||||||
final int statusCode;
|
|
||||||
final String message;
|
|
||||||
final String body;
|
|
||||||
|
|
||||||
const WooCommerceApiException({
|
|
||||||
required this.statusCode,
|
|
||||||
required this.message,
|
|
||||||
required this.body,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'WooCommerceApiException($statusCode): $message';
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import '../domain/product_draft.dart';
|
|
||||||
import '../domain/publish_status.dart';
|
|
||||||
|
|
||||||
/// Maps raw WooCommerce REST API v3 product JSON into [ProductDraft] domain
|
|
||||||
/// objects.
|
|
||||||
///
|
|
||||||
/// Only the fields needed for read-only product retrieval are mapped.
|
|
||||||
/// See https://woocommerce.github.io/woocommerce-rest-api-docs/#product-properties
|
|
||||||
class WordPressProductMapper {
|
|
||||||
const WordPressProductMapper();
|
|
||||||
|
|
||||||
/// Converts a single WooCommerce product JSON map to a [ProductDraft].
|
|
||||||
ProductDraft fromJson(Map<String, dynamic> json) {
|
|
||||||
return ProductDraft(
|
|
||||||
id: json['id']?.toString() ?? '',
|
|
||||||
name: (json['name'] as String?) ?? '',
|
|
||||||
description: _stripHtml((json['description'] as String?) ?? ''),
|
|
||||||
price: _parsePrice(json['price']),
|
|
||||||
sku: (json['sku'] as String?) ?? '',
|
|
||||||
category: _firstCategoryName(json['categories']),
|
|
||||||
imageUrl: _firstImageUrl(json['images']),
|
|
||||||
status: _mapStatus(json['status'] as String?),
|
|
||||||
lastModified: _parseDate(json['date_modified'] ?? json['date_created']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a list of WooCommerce product JSON maps to [ProductDraft]s.
|
|
||||||
List<ProductDraft> fromJsonList(List<Map<String, dynamic>> jsonList) {
|
|
||||||
return jsonList.map(fromJson).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Maps the WooCommerce `status` string to our [PublishStatus] enum.
|
|
||||||
///
|
|
||||||
/// WooCommerce statuses: `publish`, `draft`, `pending`, `private`, `trash`.
|
|
||||||
static PublishStatus _mapStatus(String? status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'publish':
|
|
||||||
return PublishStatus.published;
|
|
||||||
case 'draft':
|
|
||||||
return PublishStatus.draft;
|
|
||||||
case 'pending':
|
|
||||||
return PublishStatus.pendingReview;
|
|
||||||
case 'private':
|
|
||||||
case 'trash':
|
|
||||||
return PublishStatus.unpublished;
|
|
||||||
default:
|
|
||||||
return PublishStatus.draft;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a price value that may arrive as a String or num.
|
|
||||||
static double _parsePrice(dynamic value) {
|
|
||||||
if (value == null) return 0.0;
|
|
||||||
if (value is num) return value.toDouble();
|
|
||||||
if (value is String) return double.tryParse(value) ?? 0.0;
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts the name of the first category, or `'Uncategorized'`.
|
|
||||||
///
|
|
||||||
/// WooCommerce returns categories as:
|
|
||||||
/// ```json
|
|
||||||
/// [{"id": 1, "name": "Bowl Cozies", "slug": "bowl-cozies"}]
|
|
||||||
/// ```
|
|
||||||
static String _firstCategoryName(dynamic categories) {
|
|
||||||
if (categories is List && categories.isNotEmpty) {
|
|
||||||
final first = categories.first;
|
|
||||||
if (first is Map<String, dynamic>) {
|
|
||||||
return (first['name'] as String?) ?? 'Uncategorized';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'Uncategorized';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts the `src` URL of the first product image, or empty string.
|
|
||||||
///
|
|
||||||
/// WooCommerce returns images as:
|
|
||||||
/// ```json
|
|
||||||
/// [{"id": 1, "src": "https://...", "name": "image.jpg", ...}]
|
|
||||||
/// ```
|
|
||||||
static String _firstImageUrl(dynamic images) {
|
|
||||||
if (images is List && images.isNotEmpty) {
|
|
||||||
final first = images.first;
|
|
||||||
if (first is Map<String, dynamic>) {
|
|
||||||
return (first['src'] as String?) ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses an ISO 8601 date string, falling back to [DateTime.now].
|
|
||||||
static DateTime _parseDate(dynamic value) {
|
|
||||||
if (value is String && value.isNotEmpty) {
|
|
||||||
return DateTime.tryParse(value) ?? DateTime.now();
|
|
||||||
}
|
|
||||||
return DateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strips basic HTML tags from a string.
|
|
||||||
///
|
|
||||||
/// WooCommerce descriptions often contain `<p>`, `<br>`, etc.
|
|
||||||
static String _stripHtml(String html) {
|
|
||||||
return html
|
|
||||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll(''', "'")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue