Compare commits
2 Commits
f056d5f0b5
...
591de0c5c4
| Author | SHA1 | Date |
|---|---|---|
|
|
591de0c5c4 | |
|
|
65466ba513 |
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: test-coverage-visibility (Stage 4D complete — Stage 4 complete)
|
- main baseline updated through: android-publishing-surface (Stage 5B complete)
|
||||||
- main baseline commit: merge of `feat/test-coverage-visibility` (2026-05-22)
|
- main baseline commit: merge of `feat/android-publishing-surface` (2026-05-29)
|
||||||
- next branch: feat/android-app-shell (Stage 5A)
|
- next branch: feat/android-feedback-polish (Stage 6A)
|
||||||
- current stage: Stage 5 — Android application foundation
|
- current stage: Stage 5 complete — Stage 6 next
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -158,3 +158,43 @@
|
||||||
- analyze: passed
|
- analyze: passed
|
||||||
- coverage baseline: core 85.7%, design_system 100.0%, feature_wordpress 84.7%, kell_web 54.1%, overall 78.4%
|
- coverage baseline: core 85.7%, design_system 100.0%, feature_wordpress 84.7%, kell_web 54.1%, overall 78.4%
|
||||||
- brief updated: yes
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/android-app-shell
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- date: 2026-05-28
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- files changed:
|
||||||
|
- `kell_mobile/pubspec.yaml` — replaced default template dependencies with shared packages (`core`, `design_system`, `feature_inventory`, `feature_orders`, `feature_policy`, `feature_wordpress`); SDK constraint corrected to `^3.11.0`
|
||||||
|
- `kell_mobile/lib/main.dart` — replaced counter template with `KcBootstrap` entry point using `--dart-define` environment variables
|
||||||
|
- `kell_mobile/lib/app.dart` — new `KellMobileApp` widget with `KcAppScope<MobileAppServices>`, `KcTheme`, and environment badge
|
||||||
|
- `kell_mobile/lib/composition/mobile_app_services.dart` — new `MobileAppServices` extending `KcAppServices` with `fake()` and `wp()` factory constructors
|
||||||
|
- `kell_mobile/lib/shell/mobile_shell.dart` — new `MobileShell` with 5-tab `NavigationBar` (Dashboard, Inventory, Orders, Publishing, More) and `IndexedStack` body
|
||||||
|
- `kell_mobile/lib/dashboard/domain/dashboard_summary.dart` — shared `DashboardSummary` value object with `fromData()` and `empty()` constructors
|
||||||
|
- `kell_mobile/lib/dashboard/application/get_dashboard_summary.dart` — use case aggregating inventory, orders, and publishing repositories
|
||||||
|
- `kell_mobile/lib/dashboard/application/dashboard_controller.dart` — `ChangeNotifier` controller with loading/error/summary state
|
||||||
|
- `kell_mobile/lib/pages/dashboard_page.dart` — mobile-optimized dashboard with `GridView` summary cards using design system widgets
|
||||||
|
- `kell_mobile/lib/pages/finance_placeholder_page.dart` — placeholder page for Finance tab
|
||||||
|
- `kell_mobile/lib/pages/integrations_placeholder_page.dart` — placeholder page for Integrations tab
|
||||||
|
- `kell_mobile/test/widget_test.dart` — 6 widget tests covering shell loading, summary cards, environment badge, navigation bar, tab switching, and More menu
|
||||||
|
- 13 other `pubspec.yaml` files — SDK constraint corrected from `^3.11.4` to `^3.11.0` across all packages
|
||||||
|
- tests: passed (6/6 kell_mobile, 24/24 kell_web, 294/294 feature_wordpress — all passing)
|
||||||
|
- analyze: not yet run (SDK constraint fix was prerequisite)
|
||||||
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/android-publishing-surface
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- date: 2026-05-29
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- files changed:
|
||||||
|
- `feature_wordpress/lib/feature_wordpress.dart` — expanded barrel exports with `ProductPublishingController`, `ProductSortField`, `ProductDraftCard`, `ProductPreviewPanel`, and all 5 use cases + snack bar helpers for mobile consumption
|
||||||
|
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — new mobile-optimized publishing workspace with search bar, horizontal filter chips, sort dropdown, product count, compact card list, pull-to-refresh, and push navigation to detail page
|
||||||
|
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — new full-screen product detail page wrapping shared `ProductPreviewPanel` with all narrow edit callbacks
|
||||||
|
- `kell_mobile/lib/shell/mobile_shell.dart` — Products tab (case 2) switched from `ProductPublishingPage` to `MobilePublishingPage`; removed unused `feature_wordpress` import
|
||||||
|
- `kell_mobile/test/widget_test.dart` — added 4 new mobile publishing surface tests (search bar, filter chips, product count, sort button) — 10 total kell_mobile tests
|
||||||
|
- tests: passed (10/10 kell_mobile)
|
||||||
|
- analyze: passed (dart analyze — no issues found)
|
||||||
|
- brief updated: yes
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ Rules:
|
||||||
### Platform structure
|
### Platform structure
|
||||||
|
|
||||||
- `apps/kell_web` exists — active, wired to shared packages.
|
- `apps/kell_web` exists — active, wired to shared packages.
|
||||||
- `apps/kell_mobile` exists — scaffolded as default Flutter template, **not yet integrated** with shared packages.
|
- `apps/kell_mobile` exists — integrated with shared packages, mobile-optimized shell with bottom navigation, dashboard, and placeholder pages for all features.
|
||||||
- Shared packages include:
|
- Shared packages include:
|
||||||
- `core` — shared domain/application abstractions and cross-platform composition pattern (`KcAppConfig`, `KcAppServices`, `KcBootstrap`, `KcAppScope`)
|
- `core` — shared domain/application abstractions and cross-platform composition pattern (`KcAppConfig`, `KcAppServices`, `KcBootstrap`, `KcAppScope`)
|
||||||
- `design_system` — theme (`KcColors`, `KcSpacing`, `KcTheme`), typography (`KcTypography`), layout (`KcBreakpoints`), and 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`)
|
- `design_system` — theme (`KcColors`, `KcSpacing`, `KcTheme`), typography (`KcTypography`), layout (`KcBreakpoints`), and 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`)
|
||||||
|
|
@ -132,8 +132,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/android-app-shell`** — Stage 5A: Android app shell and bootstrap.
|
**`feat/android-feedback-polish`** — Stage 6A: Android feedback and action polish.
|
||||||
Branch from latest `main`. Stage 4 (Platform foundations and cross-platform readiness) is complete.
|
Branch from latest `main`. Stage 5 (Android application foundation) is complete.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -280,48 +280,15 @@ Business logic, domain logic, repositories, and feature application logic should
|
||||||
- `feat/android-app-shell`
|
- `feat/android-app-shell`
|
||||||
- `feat/android-publishing-surface`
|
- `feat/android-publishing-surface`
|
||||||
|
|
||||||
#### Stage 5A — Android app shell and bootstrap
|
#### ~~Stage 5A — Android app shell and bootstrap~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Goal
|
> Merged `feat/android-app-shell` → `main` (2026-05-28).
|
||||||
|
> Replaced the default Flutter counter template with a fully integrated mobile app shell. Created `MobileAppServices` extending `KcAppServices` with shared composition pattern, `KellMobileApp` with `KcAppScope<MobileAppServices>` and `KcBootstrap`, `MobileShell` with 5-tab `NavigationBar` (Dashboard, Inventory, Orders, Publishing, More). Dashboard reuses shared `DashboardSummary`/`DashboardController` with mobile-optimized `GridView` layout and design system widgets. Placeholder pages for Finance, Integrations, and feature tab content. `pubspec.yaml` references all shared packages (`core`, `design_system`, `feature_inventory`, `feature_orders`, `feature_policy`, `feature_wordpress`). Environment badge shows runtime mode. SDK constraint corrected to `^3.11.0` across all 14 pubspec files. 6 new `kell_mobile` widget tests added (6/6 kell_mobile, 24/24 kell_web, 294/294 feature_wordpress — all passing).
|
||||||
|
|
||||||
Create the Android app entry and shell for the existing platform.
|
#### ~~Stage 5B — Android publishing surface~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Requirements
|
> Merged `feat/android-publishing-surface` → `main` (2026-05-29).
|
||||||
|
> Created mobile-optimized publishing workspace: `MobilePublishingPage` with search bar, horizontal filter chips, sort dropdown with ascending/descending toggle, product count display, compact card list with pull-to-refresh, and push navigation to detail page. `MobileProductDetailPage` wraps the shared `ProductPreviewPanel` with all narrow edit callbacks (status, price, name, description, category). Products tab in `MobileShell` switched from web `ProductPublishingPage` to `MobilePublishingPage`. Expanded `feature_wordpress` barrel exports for mobile consumption (`ProductPublishingController`, `ProductSortField`, `ProductDraftCard`, `ProductPreviewPanel`, all 5 use cases, snack bar helpers). Zero business logic forked — all shared layers reused. 4 new mobile publishing surface tests added (10 total `kell_mobile` tests passing). Analyze clean. Stage 5 complete.
|
||||||
- add or adapt app target for Android
|
|
||||||
- reuse shared packages and feature modules
|
|
||||||
- preserve runtime environment selection model
|
|
||||||
- ensure FAKE mode works cleanly on Android first
|
|
||||||
- mobile shell/navigation should stay simple and consistent with shared app structure
|
|
||||||
|
|
||||||
##### Current state note
|
|
||||||
|
|
||||||
> `kell_mobile` exists as a default Flutter counter template. It does **not** yet reference any shared packages (`core`, `design_system`, `feature_*`). This stage must replace the template with a proper app shell that mirrors the `kell_web` composition pattern (`AppServices`, `AppScope`, routing, shell).
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- app runs on Android emulator/device in FAKE mode
|
|
||||||
- shell, navigation, and core screens render
|
|
||||||
- analyze/tests remain clean
|
|
||||||
- `kell_mobile/pubspec.yaml` references shared packages
|
|
||||||
|
|
||||||
#### Stage 5B — Android publishing surface
|
|
||||||
|
|
||||||
##### Goal
|
|
||||||
|
|
||||||
Adapt the publishing workflow for mobile form factor.
|
|
||||||
|
|
||||||
##### Requirements
|
|
||||||
|
|
||||||
- reuse shared controller/use case/repository layers
|
|
||||||
- optimize preview/edit interactions for smaller screens
|
|
||||||
- keep feature parity for current publishing workflow where feasible
|
|
||||||
- do not fork business rules for Android
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- Android supports browsing, filtering, status changes, and existing narrow edits
|
|
||||||
- mobile presentation is usable and tested where practical
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'shell/mobile_shell.dart';
|
||||||
|
|
||||||
|
/// Root widget for the Kell Creations mobile application.
|
||||||
|
///
|
||||||
|
/// Uses the shared [buildKcTheme] from `design_system` for consistent
|
||||||
|
/// branding across web and mobile platforms.
|
||||||
|
class KellMobileApp extends StatelessWidget {
|
||||||
|
const KellMobileApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Kell Creations',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: const MobileShell(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import 'package:core/core.dart';
|
||||||
|
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 `kell_mobile`.
|
||||||
|
///
|
||||||
|
/// Extends [KcAppServices] from the shared `core` package so that the
|
||||||
|
/// generic [KcBootstrap] and [KcAppScope] infrastructure can work with
|
||||||
|
/// this app's specific service set.
|
||||||
|
///
|
||||||
|
/// Mirrors the same service composition as `kell_web`'s `AppServices`,
|
||||||
|
/// ensuring both platforms share identical business/domain logic.
|
||||||
|
class MobileAppServices extends KcAppServices {
|
||||||
|
final InventoryRepository inventoryRepository;
|
||||||
|
final OrdersRepository ordersRepository;
|
||||||
|
final PolicyRepository policyRepository;
|
||||||
|
final ProductPublishingRepository productPublishingRepository;
|
||||||
|
|
||||||
|
const MobileAppServices({
|
||||||
|
required this.inventoryRepository,
|
||||||
|
required this.ordersRepository,
|
||||||
|
required this.policyRepository,
|
||||||
|
required this.productPublishingRepository,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||||
|
factory MobileAppServices.fake() {
|
||||||
|
return MobileAppServices(
|
||||||
|
inventoryRepository: FakeInventoryRepository(),
|
||||||
|
ordersRepository: FakeOrdersRepository(),
|
||||||
|
policyRepository: FakePolicyRepository(),
|
||||||
|
productPublishingRepository: FakeProductPublishingRepository(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a [MobileAppServices] with a real WooCommerce-backed product
|
||||||
|
/// repository. Other repositories remain fake until their backends are
|
||||||
|
/// ready.
|
||||||
|
factory MobileAppServices.wordpress({
|
||||||
|
required String siteUrl,
|
||||||
|
required String consumerKey,
|
||||||
|
required String consumerSecret,
|
||||||
|
}) {
|
||||||
|
final apiClient = WooCommerceApiClient(
|
||||||
|
siteUrl: siteUrl,
|
||||||
|
consumerKey: consumerKey,
|
||||||
|
consumerSecret: consumerSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
return MobileAppServices(
|
||||||
|
inventoryRepository: FakeInventoryRepository(),
|
||||||
|
ordersRepository: FakeOrdersRepository(),
|
||||||
|
policyRepository: FakePolicyRepository(),
|
||||||
|
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||||
|
static KcServiceFactory<MobileAppServices> get serviceFactory {
|
||||||
|
return KcServiceFactory<MobileAppServices>(
|
||||||
|
createFake: () => MobileAppServices.fake(),
|
||||||
|
createWordPress: (config) => MobileAppServices.wordpress(
|
||||||
|
siteUrl: config.wcSiteUrl,
|
||||||
|
consumerKey: config.wcConsumerKey,
|
||||||
|
consumerSecret: config.wcConsumerSecret,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
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. Mirrors `kell_web`'s equivalent controller.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
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. Mirrors `kell_web`'s equivalent use case.
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
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 mobile dashboard.
|
||||||
|
///
|
||||||
|
/// This is an app-level value object that composes data from multiple
|
||||||
|
/// feature-package repositories without leaking domain logic back into
|
||||||
|
/// those packages.
|
||||||
|
///
|
||||||
|
/// Mirrors the same domain model as `kell_web`'s `DashboardSummary`.
|
||||||
|
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,122 +1,21 @@
|
||||||
|
import 'package:core/core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app.dart';
|
||||||
|
import 'composition/mobile_app_services.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const MyApp());
|
final config = KcAppConfig.fromEnvironment();
|
||||||
}
|
final (:services, config: effectiveConfig) = KcBootstrap.run(
|
||||||
|
config,
|
||||||
class MyApp extends StatelessWidget {
|
MobileAppServices.serviceFactory,
|
||||||
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 {
|
runApp(
|
||||||
const MyHomePage({super.key, required this.title});
|
KcAppScope<MobileAppServices>(
|
||||||
|
services: services,
|
||||||
// This widget is the home page of your application. It is stateful, meaning
|
config: effectiveConfig,
|
||||||
// that it has a State object (defined below) that contains fields that affect
|
child: const KellMobileApp(),
|
||||||
// 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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../dashboard/application/dashboard_controller.dart';
|
||||||
|
import '../dashboard/domain/dashboard_summary.dart';
|
||||||
|
|
||||||
|
/// A mobile-optimized dashboard page showing aggregated summary data.
|
||||||
|
///
|
||||||
|
/// Uses a single-column scrollable layout suitable for smaller screens.
|
||||||
|
class MobileDashboardPage extends StatefulWidget {
|
||||||
|
final DashboardController controller;
|
||||||
|
|
||||||
|
const MobileDashboardPage({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileDashboardPage> createState() => _MobileDashboardPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileDashboardPageState extends State<MobileDashboardPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_onControllerChanged);
|
||||||
|
widget.controller.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(MobileDashboardPage 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(
|
||||||
|
padding: const EdgeInsets.all(KcSpacing.md),
|
||||||
|
children: [
|
||||||
|
const KcSectionHeader(title: 'Overview'),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
_buildSummaryGrid(context, summary),
|
||||||
|
const SizedBox(height: KcSpacing.xl),
|
||||||
|
const KcSectionHeader(title: 'Recent Activity'),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
const KcEmptyState(
|
||||||
|
icon: Icons.history,
|
||||||
|
message:
|
||||||
|
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) {
|
||||||
|
final cards = [
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.inventory_2,
|
||||||
|
iconColor: KcColors.denimBlue,
|
||||||
|
label: 'Total Products',
|
||||||
|
value: '${summary.totalProducts}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
iconColor: KcColors.success,
|
||||||
|
label: 'In Stock',
|
||||||
|
value: '${summary.inStock}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
iconColor: KcColors.warning,
|
||||||
|
label: 'Low Stock',
|
||||||
|
value: '${summary.lowStock}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.edit_note,
|
||||||
|
iconColor: KcColors.neutral,
|
||||||
|
label: 'Draft',
|
||||||
|
value: '${summary.draftProducts}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.receipt_long,
|
||||||
|
iconColor: KcColors.denimBlue,
|
||||||
|
label: 'Total Orders',
|
||||||
|
value: '${summary.totalOrders}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.hourglass_empty,
|
||||||
|
iconColor: KcColors.warning,
|
||||||
|
label: 'Pending Orders',
|
||||||
|
value: '${summary.pendingOrders}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.local_shipping_outlined,
|
||||||
|
iconColor: KcColors.success,
|
||||||
|
label: 'Active Orders',
|
||||||
|
value: '${summary.activeOrders}',
|
||||||
|
),
|
||||||
|
KcSummaryCard(
|
||||||
|
icon: Icons.attach_money,
|
||||||
|
iconColor: KcColors.success,
|
||||||
|
label: 'Revenue',
|
||||||
|
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: KcSpacing.sm,
|
||||||
|
mainAxisSpacing: KcSpacing.sm,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
childAspectRatio: 1.6,
|
||||||
|
children: cards,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Full-screen product detail page for mobile.
|
||||||
|
///
|
||||||
|
/// Receives the shared [ProductPublishingController] so that edits
|
||||||
|
/// (name, price, description, category, status) are immediately
|
||||||
|
/// reflected in the list when the user pops back.
|
||||||
|
///
|
||||||
|
/// Wraps the shared [ProductPreviewPanel] from [feature_wordpress]
|
||||||
|
/// inside a [Scaffold] with an [AppBar] showing the product name.
|
||||||
|
class MobileProductDetailPage extends StatelessWidget {
|
||||||
|
final ProductPublishingController controller;
|
||||||
|
|
||||||
|
const MobileProductDetailPage({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final draft = controller.selectedDraft;
|
||||||
|
|
||||||
|
// If the product was removed or deselected, pop back.
|
||||||
|
if (draft == null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(draft.name)),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ProductPreviewPanel(
|
||||||
|
draft: draft,
|
||||||
|
isUpdating: controller.isUpdating(draft.id),
|
||||||
|
onPublish: () => controller.updateStatus(draft.id, PublishStatus.published),
|
||||||
|
onMoveToDraft: () => controller.updateStatus(draft.id, PublishStatus.draft),
|
||||||
|
onPriceChanged: (price) => controller.updatePrice(draft.id, price),
|
||||||
|
onNameChanged: (name) => controller.updateName(draft.id, name),
|
||||||
|
onDescriptionChanged: (desc) => controller.updateDescription(draft.id, desc),
|
||||||
|
onCategoryChanged: (cat) => controller.updateCategory(draft.id, cat),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'mobile_product_detail_page.dart';
|
||||||
|
|
||||||
|
/// Mobile-optimized publishing workspace page.
|
||||||
|
///
|
||||||
|
/// Provides browsing, filtering, searching, sorting, and status changes
|
||||||
|
/// for the product publishing workflow on smaller screens. Tapping a
|
||||||
|
/// product pushes a full-screen detail page for viewing and editing.
|
||||||
|
///
|
||||||
|
/// Reuses the shared [ProductPublishingController] and all use cases
|
||||||
|
/// from [feature_wordpress] — no business logic is forked for mobile.
|
||||||
|
class MobilePublishingPage extends StatefulWidget {
|
||||||
|
final ProductPublishingRepository repository;
|
||||||
|
|
||||||
|
const MobilePublishingPage({super.key, required this.repository});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobilePublishingPage> createState() => _MobilePublishingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobilePublishingPageState extends State<MobilePublishingPage> {
|
||||||
|
late final ProductPublishingController _controller;
|
||||||
|
late final TextEditingController _searchController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController = TextEditingController();
|
||||||
|
|
||||||
|
final repo = widget.repository;
|
||||||
|
_controller = ProductPublishingController(
|
||||||
|
GetProductDrafts(repo),
|
||||||
|
PublishProduct(repo),
|
||||||
|
UpdateProductStatus(repo),
|
||||||
|
UpdateProductPrice(repo),
|
||||||
|
UpdateProductName(repo),
|
||||||
|
UpdateProductDescription(repo),
|
||||||
|
UpdateProductCategory(repo),
|
||||||
|
);
|
||||||
|
|
||||||
|
_controller.addListener(_onControllerChanged);
|
||||||
|
_controller.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.removeListener(_onControllerChanged);
|
||||||
|
_controller.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles action result feedback via SnackBars.
|
||||||
|
void _onControllerChanged() {
|
||||||
|
final result = _controller.lastActionResult;
|
||||||
|
if (result != null) {
|
||||||
|
_controller.consumeActionResult();
|
||||||
|
showStatusActionSnackBar(context, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
final priceResult = _controller.lastPriceResult;
|
||||||
|
if (priceResult != null) {
|
||||||
|
_controller.consumePriceResult();
|
||||||
|
showPriceActionSnackBar(context, priceResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
final nameResult = _controller.lastNameResult;
|
||||||
|
if (nameResult != null) {
|
||||||
|
_controller.consumeNameResult();
|
||||||
|
showNameActionSnackBar(context, nameResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
final descriptionResult = _controller.lastDescriptionResult;
|
||||||
|
if (descriptionResult != null) {
|
||||||
|
_controller.consumeDescriptionResult();
|
||||||
|
showDescriptionActionSnackBar(context, descriptionResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
final categoryResult = _controller.lastCategoryResult;
|
||||||
|
if (categoryResult != null) {
|
||||||
|
_controller.consumeCategoryResult();
|
||||||
|
showCategoryActionSnackBar(context, categoryResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (_controller.isLoading) {
|
||||||
|
return const KcLoadingState(message: 'Loading products…');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller.error != null) {
|
||||||
|
return KcErrorState(message: 'Failed to load product drafts.', onRetry: _controller.load);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildSearchBar(),
|
||||||
|
_buildFilterChips(),
|
||||||
|
_buildSortAndCount(),
|
||||||
|
Expanded(child: _buildProductList()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search bar with real-time filtering.
|
||||||
|
Widget _buildSearchBar() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.sm, KcSpacing.md, KcSpacing.xs),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search products…',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
_controller.setSearchQuery('');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: KcSpacing.sm,
|
||||||
|
vertical: KcSpacing.sm,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onChanged: (query) {
|
||||||
|
_controller.setSearchQuery(query);
|
||||||
|
setState(() {}); // refresh clear button visibility
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontal scrollable filter chips for status filtering.
|
||||||
|
Widget _buildFilterChips() {
|
||||||
|
const filters = [
|
||||||
|
(null, 'All'),
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('pendingReview', 'Pending'),
|
||||||
|
('published', 'Published'),
|
||||||
|
('unpublished', 'Unpublished'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
|
||||||
|
itemCount: filters.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: KcSpacing.xs),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final (value, label) = filters[index];
|
||||||
|
final isActive = _controller.activeFilter == value;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isActive,
|
||||||
|
onSelected: (_) => _controller.setFilter(isActive ? null : value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort dropdown and product count row.
|
||||||
|
Widget _buildSortAndCount() {
|
||||||
|
final count = _controller.drafts.length;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$count ${count == 1 ? 'product' : 'products'}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
PopupMenuButton<ProductSortField>(
|
||||||
|
icon: const Icon(Icons.sort, size: 20),
|
||||||
|
tooltip: 'Sort',
|
||||||
|
onSelected: (field) {
|
||||||
|
if (field == _controller.activeSortField) {
|
||||||
|
_controller.setSort(field, ascending: !_controller.sortAscending);
|
||||||
|
} else {
|
||||||
|
_controller.setSort(field, ascending: true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
_sortMenuItem(ProductSortField.name, 'Name'),
|
||||||
|
_sortMenuItem(ProductSortField.lastModified, 'Last Modified'),
|
||||||
|
_sortMenuItem(ProductSortField.status, 'Status'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupMenuEntry<ProductSortField> _sortMenuItem(ProductSortField field, String label) {
|
||||||
|
final isActive = _controller.activeSortField == field;
|
||||||
|
return PopupMenuItem(
|
||||||
|
value: field,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: isActive ? const TextStyle(fontWeight: FontWeight.bold) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isActive)
|
||||||
|
Icon(_controller.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrollable product list using compact cards.
|
||||||
|
Widget _buildProductList() {
|
||||||
|
final drafts = _controller.drafts;
|
||||||
|
|
||||||
|
if (drafts.isEmpty) {
|
||||||
|
return KcEmptyState(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
message: _controller.searchQuery.isNotEmpty || _controller.activeFilter != null
|
||||||
|
? 'No products match your criteria.'
|
||||||
|
: 'No product drafts available.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _controller.load,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
|
||||||
|
itemCount: drafts.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final draft = drafts[index];
|
||||||
|
return SizedBox(
|
||||||
|
height: 72,
|
||||||
|
child: ProductDraftCard(
|
||||||
|
draft: draft,
|
||||||
|
isSelected: draft.id == _controller.selectedDraft?.id,
|
||||||
|
isCompact: true,
|
||||||
|
isStale: _controller.isStale(draft),
|
||||||
|
onTap: () => _navigateToDetail(draft),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the full-screen product detail page.
|
||||||
|
void _navigateToDetail(ProductDraft draft) {
|
||||||
|
_controller.selectDraft(draft);
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import 'package:core/core.dart';
|
||||||
|
import 'package:feature_inventory/feature_inventory.dart';
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../composition/mobile_app_services.dart';
|
||||||
|
import '../dashboard/application/dashboard_controller.dart';
|
||||||
|
import '../dashboard/application/get_dashboard_summary.dart';
|
||||||
|
import '../pages/dashboard_page.dart';
|
||||||
|
import '../pages/finance_placeholder_page.dart';
|
||||||
|
import '../pages/integrations_placeholder_page.dart';
|
||||||
|
import '../pages/mobile_publishing_page.dart';
|
||||||
|
|
||||||
|
/// The main shell for the mobile app.
|
||||||
|
///
|
||||||
|
/// Uses a [Scaffold] with a [NavigationBar] (Material 3 bottom navigation)
|
||||||
|
/// to provide top-level section navigation. This is the mobile equivalent
|
||||||
|
/// of `kell_web`'s [AppShell] which uses a [NavigationRail].
|
||||||
|
///
|
||||||
|
/// Unlike the web app which uses named routes, the mobile shell uses
|
||||||
|
/// index-based tab switching with a stateful body to preserve tab state.
|
||||||
|
class MobileShell extends StatefulWidget {
|
||||||
|
const MobileShell({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileShell> createState() => _MobileShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileShellState extends State<MobileShell> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final config = KcAppScope.configOf<MobileAppServices>(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_titles[_selectedIndex]),
|
||||||
|
actions: [
|
||||||
|
_EnvironmentBadge(environment: config.environment),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(context),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _selectedIndex,
|
||||||
|
onDestinationSelected: (index) {
|
||||||
|
setState(() => _selectedIndex = index);
|
||||||
|
},
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.dashboard_outlined),
|
||||||
|
selectedIcon: Icon(Icons.dashboard),
|
||||||
|
label: 'Dashboard',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.inventory_2_outlined),
|
||||||
|
selectedIcon: Icon(Icons.inventory_2),
|
||||||
|
label: 'Inventory',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.sell_outlined),
|
||||||
|
selectedIcon: Icon(Icons.sell),
|
||||||
|
label: 'Products',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.receipt_long_outlined),
|
||||||
|
selectedIcon: Icon(Icons.receipt_long),
|
||||||
|
label: 'Orders',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.more_horiz),
|
||||||
|
selectedIcon: Icon(Icons.more_horiz),
|
||||||
|
label: 'More',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
final services = KcAppScope.of<MobileAppServices>(context);
|
||||||
|
|
||||||
|
switch (_selectedIndex) {
|
||||||
|
case 0:
|
||||||
|
return MobileDashboardPage(
|
||||||
|
controller: DashboardController(
|
||||||
|
GetDashboardSummary(
|
||||||
|
inventoryRepository: services.inventoryRepository,
|
||||||
|
productPublishingRepository: services.productPublishingRepository,
|
||||||
|
ordersRepository: services.ordersRepository,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return InventoryPage(
|
||||||
|
repository: services.inventoryRepository,
|
||||||
|
onViewProduct: (_) {
|
||||||
|
// Cross-feature nav: switch to Products tab.
|
||||||
|
setState(() => _selectedIndex = 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return MobilePublishingPage(repository: services.productPublishingRepository);
|
||||||
|
case 3:
|
||||||
|
return OrdersPage(
|
||||||
|
repository: services.ordersRepository,
|
||||||
|
onViewProduct: (_) {
|
||||||
|
setState(() => _selectedIndex = 2);
|
||||||
|
},
|
||||||
|
onViewInventory: (_) {
|
||||||
|
setState(() => _selectedIndex = 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return const _MorePage();
|
||||||
|
default:
|
||||||
|
return const Center(child: Text('Unknown section'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple "More" page that provides access to less frequently used sections.
|
||||||
|
class _MorePage extends StatelessWidget {
|
||||||
|
const _MorePage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.attach_money),
|
||||||
|
title: const Text('Finance'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Finance')),
|
||||||
|
body: const FinancePlaceholderPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.policy),
|
||||||
|
title: const Text('Policy'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
final services = KcAppScope.of<MobileAppServices>(context);
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Policy')),
|
||||||
|
body: PolicyPage(
|
||||||
|
repository: services.policyRepository,
|
||||||
|
onViewRelatedPage: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.hub),
|
||||||
|
title: const Text('Integrations'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Integrations')),
|
||||||
|
body: const IntegrationsPlaceholderPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small coloured chip displayed in the [AppBar] that shows the current
|
||||||
|
/// runtime environment (e.g. "FAKE" or "WP").
|
||||||
|
class _EnvironmentBadge extends StatelessWidget {
|
||||||
|
final KcAppEnvironment environment;
|
||||||
|
|
||||||
|
const _EnvironmentBadge({required this.environment});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color foregroundColor;
|
||||||
|
|
||||||
|
switch (environment) {
|
||||||
|
case KcAppEnvironment.fake:
|
||||||
|
backgroundColor = Colors.orange.shade100;
|
||||||
|
foregroundColor = Colors.orange.shade900;
|
||||||
|
case KcAppEnvironment.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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,13 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
core:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../../packages/core"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -49,6 +56,13 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
|
design_system:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../../packages/design_system"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -57,6 +71,34 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
|
feature_inventory:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../../packages/feature_inventory"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
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
|
||||||
|
|
@ -75,6 +117,22 @@ 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:
|
||||||
|
|
@ -192,6 +250,14 @@ 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:
|
||||||
|
|
@ -208,6 +274,14 @@ 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.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,36 @@
|
||||||
name: kell_mobile
|
name: kell_mobile
|
||||||
description: "A new Flutter project."
|
description: "Kell Creations mobile operations platform."
|
||||||
# The following line prevents the package from being accidentally published to
|
publish_to: "none"
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
||||||
|
|
||||||
# The following defines the version and build number for your application.
|
|
||||||
# A version number is three numbers separated by dots, like 1.2.43
|
|
||||||
# followed by an optional build number separated by a +.
|
|
||||||
# Both the version and the builder number may be overridden in flutter
|
|
||||||
# build by specifying --build-name and --build-number, respectively.
|
|
||||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
|
||||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
|
||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
|
||||||
# Read more about iOS versioning at
|
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
|
||||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
|
||||||
# dependencies can be manually updated by changing the version numbers below to
|
|
||||||
# the latest version available on pub.dev. To see which dependencies have newer
|
|
||||||
# versions available, run `flutter pub outdated`.
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
|
core:
|
||||||
|
path: ../../packages/core
|
||||||
|
design_system:
|
||||||
|
path: ../../packages/design_system
|
||||||
|
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:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
|
||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
|
||||||
# package. See that file for information about deactivating specific lint
|
|
||||||
# rules and activating additional ones.
|
|
||||||
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:
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
|
||||||
# included with your application, so that you can use the icons in
|
|
||||||
# the material Icons class.
|
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
|
||||||
# assets:
|
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
|
||||||
|
|
||||||
# For details regarding adding assets from package dependencies, see
|
|
||||||
# https://flutter.dev/to/asset-from-package
|
|
||||||
|
|
||||||
# To add custom fonts to your application, 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 from package dependencies,
|
|
||||||
# see https://flutter.dev/to/font-from-package
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,129 @@
|
||||||
// This is a basic Flutter widget test.
|
import 'package:core/core.dart';
|
||||||
//
|
|
||||||
// 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_mobile/app.dart';
|
||||||
|
import 'package:kell_mobile/composition/mobile_app_services.dart';
|
||||||
|
|
||||||
import 'package:kell_mobile/main.dart';
|
Widget _buildTestApp() {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
return KcAppScope<MobileAppServices>(
|
||||||
|
services: MobileAppServices.fake(),
|
||||||
|
config: config,
|
||||||
|
child: const KellMobileApp(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('mobile shell loads with dashboard tab', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
await tester.pumpWidget(_buildTestApp());
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
expect(find.text('Dashboard'), findsWidgets);
|
||||||
expect(find.text('0'), findsOneWidget);
|
});
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
testWidgets('dashboard shows summary cards after loading', (WidgetTester tester) async {
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
await tester.pumpWidget(_buildTestApp());
|
||||||
await tester.pump();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
expect(find.text('Overview'), findsOneWidget);
|
||||||
expect(find.text('0'), findsNothing);
|
expect(find.text('Total Products'), findsOneWidget);
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(find.text('In Stock'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('environment badge shows FAKE in fake mode', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('FAKE'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
|
expect(find.byType(NavigationDestination), findsNWidgets(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap the Inventory destination
|
||||||
|
await tester.tap(find.text('Inventory').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The app bar title should change
|
||||||
|
expect(find.text('Inventory'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping More tab shows additional sections', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap the More destination
|
||||||
|
await tester.tap(find.text('More').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Finance'), findsOneWidget);
|
||||||
|
expect(find.text('Policy'), findsOneWidget);
|
||||||
|
expect(find.text('Integrations'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mobile Publishing Surface tests ──────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('Products tab shows mobile publishing page with search bar', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap the Products destination
|
||||||
|
await tester.tap(find.text('Products').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should show the search bar
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
expect(find.text('Search products…'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Products tab shows filter chips', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Products').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should show filter chips for status categories
|
||||||
|
expect(find.widgetWithText(FilterChip, 'All'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilterChip, 'Draft'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilterChip, 'Published'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Products tab shows product count', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Products').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Fake data should produce a product count label
|
||||||
|
expect(find.textContaining('products'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Products tab shows sort button', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Products').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should have a sort icon/button
|
||||||
|
expect(find.byIcon(Icons.sort), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,5 +283,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.4 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ export 'src/domain/product_publishing_repository.dart';
|
||||||
export 'src/domain/publish_status.dart';
|
export 'src/domain/publish_status.dart';
|
||||||
|
|
||||||
// Application
|
// Application
|
||||||
export 'src/application/product_publishing_controller.dart' show ListDensity, ProductSortField;
|
export 'src/application/get_product_drafts.dart';
|
||||||
|
export 'src/application/product_publishing_controller.dart';
|
||||||
|
export 'src/application/publish_product.dart';
|
||||||
export 'src/application/update_product_category.dart';
|
export 'src/application/update_product_category.dart';
|
||||||
export 'src/application/update_product_description.dart';
|
export 'src/application/update_product_description.dart';
|
||||||
export 'src/application/update_product_name.dart';
|
export 'src/application/update_product_name.dart';
|
||||||
|
|
@ -21,3 +23,7 @@ export 'src/application/update_product_status.dart';
|
||||||
|
|
||||||
// Presentation
|
// Presentation
|
||||||
export 'src/presentation/product_publishing_page.dart';
|
export 'src/presentation/product_publishing_page.dart';
|
||||||
|
export 'src/presentation/widgets/product_draft_card.dart';
|
||||||
|
export 'src/presentation/widgets/product_preview_panel.dart';
|
||||||
|
export 'src/presentation/widgets/publish_status_chip.dart';
|
||||||
|
export 'src/presentation/widgets/status_action_snack_bar.dart';
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
||||||
homepage:
|
homepage:
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue