Compare commits

..

No commits in common. "main" and "feature/wp-publish-draft" have entirely different histories.

333 changed files with 1628 additions and 52616 deletions

View File

@ -1,6 +0,0 @@
# Execution & Workflow Rules
- Always run tests after making code changes.
- Do not modify configuration files (.env, docker-compose.yml, deployment scripts) without explicit user instructions.
- Keep terminal commands scoped to the current project directory.
- Work in modular, step-by-step implementations rather than massive multi-file overhauls.

View File

@ -1,77 +0,0 @@
name: Flutter Analyze
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
jobs:
analyze:
name: Dart Analyze
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:stable
defaults:
run:
working-directory: kell_creations_apps
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies — core
run: cd packages/core && flutter pub get
- name: Install dependencies — design_system
run: cd packages/design_system && flutter pub get
- name: Install dependencies — feature_wordpress
run: cd packages/feature_wordpress && flutter pub get
- name: Install dependencies — feature_inventory
run: cd packages/feature_inventory && flutter pub get
- name: Install dependencies — feature_orders
run: cd packages/feature_orders && flutter pub get
- name: Install dependencies — feature_policy
run: cd packages/feature_policy && flutter pub get
- name: Install dependencies — kell_web
run: cd apps/kell_web && flutter pub get
- name: Install dependencies — kell_mobile
run: cd apps/kell_mobile && flutter pub get
- name: Analyze — core
run: cd packages/core && dart analyze --fatal-infos
- name: Analyze — design_system
run: cd packages/design_system && dart analyze --fatal-infos
- name: Analyze — feature_wordpress
run: cd packages/feature_wordpress && dart analyze --fatal-infos
- name: Analyze — feature_inventory
run: cd packages/feature_inventory && dart analyze --fatal-infos
- name: Analyze — feature_orders
run: cd packages/feature_orders && dart analyze --fatal-infos
- name: Analyze — feature_policy
run: cd packages/feature_policy && dart analyze --fatal-infos
- name: Analyze — kell_web
run: cd apps/kell_web && dart analyze --fatal-infos
- name: Analyze — kell_mobile
run: cd apps/kell_mobile && dart analyze --fatal-infos
- name: Summary
if: always()
run: echo "✅ Dart analyze completed for all packages and apps"

View File

@ -1,162 +0,0 @@
name: Flutter Test
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
jobs:
test:
name: Flutter Tests
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:stable
defaults:
run:
working-directory: kell_creations_apps
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies — core
run: cd packages/core && flutter pub get
- name: Install dependencies — design_system
run: cd packages/design_system && flutter pub get
- name: Install dependencies — feature_wordpress
run: cd packages/feature_wordpress && flutter pub get
- name: Install dependencies — kell_web
run: cd apps/kell_web && flutter pub get
- name: Test with coverage — core
run: |
cd packages/core
flutter test --coverage --reporter expanded 2>&1 | tee test_output.txt
echo ""
echo "=== core test summary ==="
TOTAL=$(grep -cE '^\s*✓' test_output.txt || echo "0")
FAILED=$(grep -cE '^\s*✗' test_output.txt || echo "0")
echo " Passed: $TOTAL"
echo " Failed: $FAILED"
# Fail the step if any tests failed
if [ "$FAILED" -gt 0 ]; then exit 1; fi
- name: Test with coverage — design_system
run: |
cd packages/design_system
flutter test --coverage --reporter expanded 2>&1 | tee test_output.txt
echo ""
echo "=== design_system test summary ==="
TOTAL=$(grep -cE '^\s*✓' test_output.txt || echo "0")
FAILED=$(grep -cE '^\s*✗' test_output.txt || echo "0")
echo " Passed: $TOTAL"
echo " Failed: $FAILED"
if [ "$FAILED" -gt 0 ]; then exit 1; fi
- name: Test with coverage — feature_wordpress
run: |
cd packages/feature_wordpress
flutter test --coverage --reporter expanded 2>&1 | tee test_output.txt
echo ""
echo "=== feature_wordpress test summary ==="
TOTAL=$(grep -cE '^\s*✓' test_output.txt || echo "0")
FAILED=$(grep -cE '^\s*✗' test_output.txt || echo "0")
echo " Passed: $TOTAL"
echo " Failed: $FAILED"
if [ "$FAILED" -gt 0 ]; then exit 1; fi
- name: Test with coverage — kell_web
run: |
cd apps/kell_web
flutter test --coverage --reporter expanded 2>&1 | tee test_output.txt
echo ""
echo "=== kell_web test summary ==="
TOTAL=$(grep -cE '^\s*✓' test_output.txt || echo "0")
FAILED=$(grep -cE '^\s*✗' test_output.txt || echo "0")
echo " Passed: $TOTAL"
echo " Failed: $FAILED"
if [ "$FAILED" -gt 0 ]; then exit 1; fi
- name: Aggregate test and coverage report
if: always()
run: |
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Flutter Test & Coverage Summary ║"
echo "╠═══════════════════════════════════════════════════════════╣"
echo "║ Package Pass Fail Lines Coverage ║"
echo "╠═══════════════════════════════════════════════════════════╣"
TOTAL_PASS=0
TOTAL_FAIL=0
TOTAL_HIT=0
TOTAL_FOUND=0
for pkg in packages/core packages/design_system packages/feature_wordpress apps/kell_web; do
NAME=$(basename "$pkg")
OUTPUT="$pkg/test_output.txt"
LCOV="$pkg/coverage/lcov.info"
# Test counts
if [ -f "$OUTPUT" ]; then
PASS=$(grep -cE '^\s*✓' "$OUTPUT" || echo "0")
FAIL=$(grep -cE '^\s*✗' "$OUTPUT" || echo "0")
else
PASS="—"
FAIL="—"
fi
# Coverage from lcov.info
if [ -f "$LCOV" ]; then
LF=$(grep -oP '(?<=LF:)\d+' "$LCOV" | awk '{s+=$1} END {print s+0}')
LH=$(grep -oP '(?<=LH:)\d+' "$LCOV" | awk '{s+=$1} END {print s+0}')
if [ "$LF" -gt 0 ]; then
PCT=$(awk "BEGIN {printf \"%.1f%%\", ($LH/$LF)*100}")
else
PCT="N/A"
fi
LINES="$LH/$LF"
else
LINES="—"
PCT="—"
LF=0
LH=0
fi
printf "║ %-20s %-7s %-7s %-8s %-10s ║\n" "$NAME" "$PASS" "$FAIL" "$LINES" "$PCT"
if [ "$PASS" != "—" ]; then TOTAL_PASS=$((TOTAL_PASS + PASS)); fi
if [ "$FAIL" != "—" ]; then TOTAL_FAIL=$((TOTAL_FAIL + FAIL)); fi
TOTAL_HIT=$((TOTAL_HIT + LH))
TOTAL_FOUND=$((TOTAL_FOUND + LF))
done
if [ "$TOTAL_FOUND" -gt 0 ]; then
TOTAL_PCT=$(awk "BEGIN {printf \"%.1f%%\", ($TOTAL_HIT/$TOTAL_FOUND)*100}")
TOTAL_LINES="$TOTAL_HIT/$TOTAL_FOUND"
else
TOTAL_PCT="—"
TOTAL_LINES="—"
fi
echo "╠═══════════════════════════════════════════════════════════╣"
printf "║ %-20s %-7s %-7s %-8s %-10s ║\n" "TOTAL" "$TOTAL_PASS" "$TOTAL_FAIL" "$TOTAL_LINES" "$TOTAL_PCT"
echo "╚═══════════════════════════════════════════════════════════╝"
if [ "$TOTAL_FAIL" -gt 0 ]; then
echo ""
echo "❌ Some tests failed. See individual package results above."
exit 1
else
echo ""
echo "✅ All $TOTAL_PASS tests passed across all packages."
echo "📊 Overall line coverage: $TOTAL_PCT ($TOTAL_LINES lines)"
fi

3
.gitignore vendored
View File

@ -4,6 +4,3 @@ __pycache__/
.venv/
.DS_Store
Thumbs.db
# Flutter test coverage output
coverage/

View File

@ -1,6 +0,0 @@
{
"MD013": false,
"MD024": {
"siblings_only": true
}
}

View File

@ -1,102 +0,0 @@
# Workspace Development Rules: Kell Creations Platform
## 1. Core Operating Principles
1. **Vertical Slice Discipline:** Implement features in isolated, reviewable vertical slices on feature branches. Never push unverified, broad changes directly to `main`[cite: 3].
2. **Inspection First:** Always inspect the codebase, verify existing tests, and review existing contracts before writing or modifying code[cite: 3].
3. **Environment Isolation:** Maintain runtime environment modes (`fake`, `wp`, `square`, `multi`) specified exclusively via `--dart-define` keys[cite: 2, 3]. Never build hardcoded settings UIs or commit API keys/tokens to source control[cite: 3].
4. **Strict Package Boundaries:**
- Feature packages (`feature_*`) must remain decoupled[cite: 1, 3].
- Cross-feature communication must use shared abstractions in `core`, `integrations`, or `data`[cite: 1, 3].
- Platform-specific UI code must reside in `apps/kell_web` or `apps/kell_mobile`[cite: 1, 3]. Re-use shared presenters and controllers[cite: 2, 3].
---
## 2. Code & Design Standards
### Flutter & Dart Execution
- Strict typing required; avoid `dynamic` unless explicitly serializing raw JSON[cite: 2, 3].
- Use Material Design 3 guidelines[cite: 2, 3]. On mobile (`kell_mobile`), ensure all interactive targets adhere to a minimum 48×48dp target bounding box[cite: 2, 3].
- Wrap mobile screens in `SafeArea` to avoid system overlay clipping[cite: 2, 3].
### Architecture Layering (per Package)
lib/
├── src/
│ ├── domain/ # Entities, Value Objects, Repository Contracts (Pure Dart)
│ ├── application/ # Use Cases, Controllers (ChangeNotifiers)
│ ├── data/ # ApiClients, Mappers, Fake & Real Repositories
│ └── presentation/ # Widgets, Pages, Dialogs, SnackBars
└── feature_name.dart # Barrel File Exports
---
## 3. Standard Execution Workflow for AI Agents (Cline / VS Code)
Whenever initiating a new task or slice, execute the following workflow steps sequentially[cite: 3]:
1. **Step 1: Context Verification**
- Read `docs/development/master_development_brief.md` and `docs/development/build_execution_tracker.md` to identify current status[cite: 2, 3].
2. **Step 2: Pre-flight Validation**
- Run local test suite using the CI script:
```bash
bash tools/run_all_tests.sh --analyze
```
- Confirm all tests pass and static analysis is clean prior to editing[cite: 2, 3].
3. **Step 3: Implementation**
- Create target feature branch (e.g., `feat/square-catalog-sync`)[cite: 2].
- Implement domain models, repository contracts, API mappers, application logic, and UI components[cite: 2, 3].
4. **Step 4: Testing & Hardening**
- Write unit tests for domain models and application controllers[cite: 2].
- Write widget tests for presentation components[cite: 2].
- Verify static analysis passes without warnings:
```bash
dart analyze --fatal-infos
```
5. **Step 5: Documentation & Tracking**
- Update `docs/development/build_execution_tracker.md` with:
- Files modified/added[cite: 2].
- Test counts and pass state[cite: 2].
- Verification/analysis result[cite: 2].
- Update `docs/development/master_development_brief.md` next branch indicator[cite: 2, 3].
---
## 4. Prompt Template for AI Sessions
Continue the Kell Creations Flutter operations platform workstream from current main.
Read docs/development/master_development_brief.md first and follow it as the authoritative planning document.
Constraints:
- Preserve strict package boundaries.
- Fake repositories remain in feature packages.
- Real integrations are incremental alongside fake ones.
- Runtime selection stays via --dart-define, not settings UI.
- No credentials hardcoded or committed.
- Keep WooCommerce details inside the WP repository layer.
- Keep Square details inside a dedicated Square integration layer.
- Multi-channel sync contracts before implementations.
- Use small, reviewable steps only.
Working rules:
1. Inspect first. Do not edit immediately.
2. Summarize current relevant state from main.
3. Propose the smallest implementation plan for the requested slice.
4. Implement only that slice.
5. Add/update focused tests.
6. Run validation (run_all_tests.sh --analyze).
7. Report changed files and validation results.
8. Update master_development_brief.md and build_execution_tracker.md.

View File

@ -1,686 +0,0 @@
# PROJECT_STATE_AUDIT.md
## Kell Creations Operations Platform — Comprehensive Code Audit
**Audit Date:** 2026-07-25
**Last Reconciled:** 2026-09-25 (`chore/state-reconciliation-flutter-347`)
**Auditor:** Senior Software Engineering Review Board (Automated)
**Baseline Commit:** `feat/square-catalog-sync-repositories` → `main` (Stage 10C complete)
**Current Branch Work:** Stage 10D (`SquareCatalogSyncService`) — complete, pending merge
**Total Tests (baseline):** 1,015 / 1,015 passing
**Static Analysis:** `dart analyze --fatal-infos` — clean across all 11 packages/apps
> **Reconciliation notice (2026-09-25).** Sections 1–4 below are preserved as the
> historical 2026-07-25 audit record and are **not** rewritten in place. Since that
> date Stages 10C (POS), 10D, 10E, 10F, 11A, and 16A have all merged to `main`, so
> many "❌ Missing" findings in those sections are now closed. §5 has been
> re-baselined against `main` as of 2026-09-25 and is the authoritative scorecard.
> A duplicated copy of §5 (a bad append in an earlier edit) was removed on the same
> date.
---
## 1. Repository Architecture Overview
### Monorepo Structure
```text
kell_creations/
├── kell_creations_apps/
│ ├── apps/
│ │ ├── kell_web/ # Flutter Web app (Windows Desktop / Browser)
│ │ └── kell_mobile/ # Flutter Android app
│ ├── packages/
│ │ ├── core/ # Shared composition abstractions
│ │ ├── design_system/ # Shared UI components and theme
│ │ ├── auth/ # Authentication contracts + fake impl
│ │ ├── data/ # Data layer contracts + in-memory impls
│ │ ├── integrations/ # API client contracts + Square/WC clients
│ │ ├── feature_wordpress/ # Product catalog management (most mature)
│ │ ├── feature_inventory/ # Inventory management
│ │ ├── feature_orders/ # Order management
│ │ ├── feature_policy/ # Policy/governance compliance
│ │ ├── feature_finance/ # Financial reporting [STUB]
│ │ ├── feature_mrp/ # Manufacturing/MRP [STUB]
│ │ └── feature_social/ # Social media management [STUB]
│ └── tools/ # CI helper scripts
├── docs/ # MkDocs documentation site
├── architecture/ # PlantUML C4 diagrams (19 diagrams)
└── policies/ # Controlled policy document repository
```
### Architectural Pattern
- **Domain-Driven Design** with strict package boundaries
- **Repository pattern** with fake/real runtime selection via `--dart-define`
- **ChangeNotifier controllers** for unidirectional state flow (no Riverpod yet)
- **Shared composition pattern** (`KcAppConfig`, `KcBootstrap`, `KcAppScope`) across both app targets
- **Vertical slice discipline** — each feature has domain → application → data → presentation layers
---
## 2. Audit Matrix — Built vs. Remaining
### 2.1 Infrastructure Packages
#### `core` — [COMPLETE]
| Artifact | Status |
| ------------------------------------------------------------ | ---------------- |
| `KcAppConfig` / `KcAppEnvironment` (fake, wp, square, multi) | ✅ Complete |
| `KcAppServices` abstract base | ✅ Complete |
| `KcServiceFactory<T>` | ✅ Complete |
| `KcBootstrap` | ✅ Complete |
| `KcAppScope<T>` InheritedWidget | ✅ Complete |
| Tests | ✅ 24/24 passing |
**Notes:** `KcBootstrap` only validates WooCommerce credentials. Square/multi mode credential validation is not yet wired into the bootstrap guard logic — `AppServices` and `MobileAppServices` have no `square` or `multi` factory constructors, meaning `KC_ENV=square` silently falls through to fake mode at runtime.
---
#### `design_system` — [COMPLETE]
| Artifact | Status |
| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `KcColors`, `KcSpacing`, `KcTheme` | ✅ Complete |
| `KcTypography` (full Material 3 scale) | ✅ Complete |
| `KcBreakpoints` (responsive layout utilities) | ✅ Complete |
| 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`) | ✅ Complete |
| Tests | ✅ 41/41 passing, 100% line coverage |
**Notes:** No shared button/action component library exists. All action chips and buttons are defined inline per-page. This is a low-priority gap.
---
#### `auth` — [PARTIAL]
| Artifact | Status |
| ------------------------------------------------- | ---------------- |
| `KcUser` / `KcUserRole` domain models | ✅ Complete |
| `AuthState` sealed hierarchy | ✅ Complete |
| `AuthService` abstract contract | ✅ Complete |
| `AuthException` | ✅ Complete |
| `CredentialStore` / `CredentialKeys` contract | ✅ Complete |
| `InMemoryCredentialStore` | ✅ Complete |
| `FakeAuthService` | ✅ Complete |
| `SecureCredentialStore` (flutter_secure_storage) | ❌ Missing |
| Real `AuthService` implementation (login/session) | ❌ Missing |
| Auth guard / login screen wired into app shell | ❌ Missing |
| Tests | ✅ 42/42 passing |
**Notes:** The auth package provides contracts and fakes only. No production implementation exists. The app is currently unguarded — any user can access all features without authentication. `auth` is not imported by either `AppServices` or `MobileAppServices`.
---
#### `data` — [PARTIAL]
| Artifact | Status |
| -------------------------------------------------- | ---------------- |
| `LocalCache<T>` contract + `InMemoryCache<T>` | ✅ Complete |
| `SyncQueue` contract + `InMemorySyncQueue` | ✅ Complete |
| `ConflictResolver<T>` + 3 concrete implementations | ✅ Complete |
| `ChangeTracker<T>` + `InMemoryChangeTracker<T>` | ✅ Complete |
| `DataMapper<TLocal, TRemote>` contract | ✅ Complete |
| SQLite-backed `LocalCache<T>` implementation | ❌ Missing |
| SQLite-backed `SyncQueue` implementation | ❌ Missing |
| Integration with any feature package | ❌ Missing |
| Tests | ✅ 63/63 passing |
**Notes:** All implementations are in-memory only. The `data` package is not imported by any feature package or app. It is a well-designed contract layer with zero production wiring. SQLite persistence (required for offline capability, Stage 14) is not implemented.
---
#### `integrations` — [PARTIAL]
| Artifact | Status |
| -------------------------------------------------------------------------- | ------------------------------------------ |
| `ApiClient` / `ApiException` contracts | ✅ Complete |
| `RetryPolicy` + `NoRetryPolicy` + `ExponentialBackoffRetryPolicy` | ✅ Complete |
| `RateLimiter` + `NoRateLimiter` + `TokenBucketRateLimiter` | ✅ Complete |
| `IntegrationHealthCheck` contract | ✅ Complete |
| `WebhookHandler` contract | ✅ Complete |
| `ChannelAdapter` contract | ✅ Complete |
| `SquareApiClient` (full lifecycle, Catalog/Inventory/Orders/Payments APIs) | ✅ Complete |
| `SquareApiException` | ✅ Complete |
| `SquareProductMapper` (Square CatalogObject ↔ `ChannelProduct`) | ✅ Complete |
| `SquareInventoryMapper` (Square InventoryCount ↔ `ChannelInventoryCount`) | ✅ Complete |
| `WooCommerceApiClient` (refactored to implement `ApiClient`) | ✅ Complete (lives in `feature_wordpress`) |
| `RetryPolicy` / `RateLimiter` wired into any real client | ❌ Missing |
| `IntegrationHealthCheck` concrete implementation | ❌ Missing |
| `WebhookHandler` concrete implementation | ❌ Missing |
| `ChannelAdapter` concrete implementation for WooCommerce or Square | ❌ Missing |
| Tests | ✅ 123/123 passing |
**Notes:** `RetryPolicy` and `RateLimiter` are defined but not wired into `SquareApiClient` or `WooCommerceApiClient`. All API calls are single-attempt with no retry or rate limiting in production paths. `ChannelAdapter` is a contract with no concrete implementation — the WooCommerce and Square clients do not implement it.
---
### 2.2 Feature Packages
#### `feature_wordpress` — [COMPLETE] (most mature)
| Artifact | Status |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `ProductDraft` domain model (full fields, `copyWith`) | ✅ Complete |
| `PublishStatus` enum (draft, published, pendingReview, unpublished) | ✅ Complete |
| `ProductCategory` domain model | ✅ Complete |
| `ProductImage` domain model | ✅ Complete |
| `ProductIdMapping` domain model | ✅ Complete |
| `ProductPublishingRepository` contract (full CRUD + categories + images) | ✅ Complete |
| `ProductIdMappingRepository` contract | ✅ Complete |
| `FakeProductPublishingRepository` (seeded, all methods) | ✅ Complete |
| `FakeProductIdMappingRepository` (seeded, 6 mappings) | ✅ Complete |
| `WordPressProductPublishingRepository` (WooCommerce REST API v3) | ✅ Complete |
| `SquareCatalogRepository` (Square API, in-memory cache) | ✅ Complete |
| `WooCommerceApiClient` (Products, Orders, Stock, Categories, Images) | ✅ Complete |
| `WordPressProductMapper` | ✅ Complete |
| All use cases (GetProductDrafts, PublishProduct, UpdateProduct\*, CreateProduct, DeleteProduct, GetCategories, CreateCategory, UpdateProductImages) | ✅ Complete |
| `ProductPublishingController` (full state: multi-select, bulk actions, create, categories) | ✅ Complete |
| `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter` | ✅ Complete (Stage 10D, pending merge) |
| `ProductPublishingPage` (web, full feature surface) | ✅ Complete |
| `CategoryManagementPage` | ✅ Complete |
| `ProductPreviewPanel` (all inline edits, touch targets) | ✅ Complete |
| `ProductDraftCard` (compact/standard, multi-select, stale indicator) | ✅ Complete |
| `CreateProductDialog` (web) | ✅ Complete |
| `SquareSyncController` (Riverpod/ChangeNotifier wrapping `SquareCatalogSyncService`) | ❌ Missing (Stage 10E — next branch) |
| Image picker / gallery management UI | ❌ Missing (deferred post-Stage 10) |
| Tests | ✅ 506/506 passing (Stage 10D) |
**Notes:** The `SquareCatalogRepository` throws `UnsupportedError` for all category and image operations — this is intentional and documented, but callers must handle these exceptions. The `SquareSyncController` (Stage 10E) is the immediate next deliverable.
---
#### `feature_inventory` — [PARTIAL]
| Artifact | Status |
| -------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `InventoryItem` domain model (full fields including `squareVariationId`, `wooCommerceProductId`, `syncStatus`) | ✅ Complete |
| `InventoryStatus` enum | ✅ Complete |
| `InventorySyncStatus` enum | ✅ Complete |
| `InventoryAdjustmentResult` value object | ✅ Complete |
| `InventoryRepository` contract (read + write + sync) | ✅ Complete |
| `FakeInventoryRepository` (seeded, all write methods) | ✅ Complete |
| `WooCommerceInventoryRepository` | ✅ Complete |
| `SquareInventoryRepository` | ✅ Complete |
| `InventorySyncService` (WooCommerce pull/push sync) | ✅ Complete |
| All use cases (GetInventoryItems, AdjustInventoryQuantity, CreateInventoryItem, UpdateInventoryItem) | ✅ Complete |
| `InventoryController` (read + write actions) | ✅ Complete |
| `InventoryPage` (web, full filter/search/detail panel) | ✅ Complete |
| `InventoryDetailPanel`, `AdjustQuantityDialog`, `InventoryStatusChip` | ✅ Complete |
| `ItemType` enum (rawMaterial, finishedGood, packaging, tool) | ❌ Missing (Stage 12A) |
| `InventoryAdjustment` audit log value object | ❌ Missing (Stage 12A) |
| `Supplier` domain model | ❌ Missing (Stage 12B) |
| `PurchaseOrder` domain model | ❌ Missing (Stage 12B) |
| Supply receiving / material usage workflows | ❌ Missing (Stage 12B) |
| Multi-channel inventory sync engine (`InventorySyncEngine`) | ❌ Missing (Stage 12C) |
| `ChannelStockMapping` | ❌ Missing (Stage 12C) |
| Sync status indicators in inventory UI | ❌ Missing |
| Tests | ✅ 167/167 passing |
**Notes:** The inventory feature has a solid domain and data layer. The `InventorySyncService` is WooCommerce-only. Square inventory sync is handled separately via `SquareInventoryRepository` but there is no unified sync engine that coordinates both channels. The `InventoryController` is not wired to `InventorySyncService` — sync must be triggered manually.
---
#### `feature_orders` — [PARTIAL]
| Artifact | Status |
| --------------------------------------------------------------------------------------------------------- | ---------------------- |
| `Order` domain model (full fields: customerPhone, notes, orderNotes, paymentMethod, source, customFields) | ✅ Complete |
| `OrderItem` domain model (productId, variationId, customizations) | ✅ Complete |
| `OrderStatus` enum (including onHold, refunded, failed) | ✅ Complete |
| `OrderSource` enum (online, market, manual) | ✅ Complete |
| `OrderActionResult` value object | ✅ Complete |
| `OrdersRepository` contract (getOrders, getOrder, createOrder, updateOrderStatus, addOrderNote) | ✅ Complete |
| `FakeOrdersRepository` (seeded, all write methods) | ✅ Complete |
| `WooCommerceOrdersRepository` | ✅ Complete |
| `WooCommerceOrderMapper` | ✅ Complete |
| All use cases (GetOrders, UpdateOrderStatus, AddOrderNote, CreateOrder) | ✅ Complete |
| `OrdersController` (read + write, filter/search persistence) | ✅ Complete |
| `OrdersPage` (web, master-detail, status filter, cross-feature callbacks) | ✅ Complete |
| `OrderDetailPanel`, `OrderCard`, `AddNoteDialog`, `OrderStatusChip` | ✅ Complete |
| `OrderType` enum (standard, custom) | ❌ Missing (Stage 11A) |
| `CustomOrderSpec` value object | ❌ Missing (Stage 11A) |
| `DepositInfo` value object | ❌ Missing (Stage 11A) |
| Custom order fulfillment status pipeline | ❌ Missing (Stage 11C) |
| `CustomOrderFormPage` | ✅ Delivered (11B) |
| `CustomOrderTrackingPage` | ❌ Missing (Stage 11C) |
| `OrderChannel` enum (woocommerce, square, manual) | ❌ Missing (Stage 13B) |
| `UnifiedOrdersController` (multi-channel aggregation) | ❌ Missing (Stage 13B) |
| Standard scaffolding files (`.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md`) | ❌ Missing |
| Tests | ✅ 198/198 passing |
**Notes:** The `feature_orders` package is missing standard monorepo scaffolding files present in all other packages. The WooCommerce orders integration is production-ready. Square orders are not yet integrated into the orders feature (Square API client has `createOrder`/`listOrders`/`getOrder` but no `SquareOrdersRepository` exists).
---
#### `feature_policy` — [PARTIAL]
| Artifact | Status |
| --------------------------------------------------------------------- | ---------------------------------------------------------- |
| `PolicyCheckResult` domain model | ✅ Complete |
| `GovernanceStatus` enum | ✅ Complete |
| `PolicyRepository` contract | ✅ Complete |
| `FakePolicyRepository` | ✅ Complete |
| `GetPolicyChecks` use case | ✅ Complete |
| `PolicyController` | ✅ Complete |
| `PolicyPage` (master-detail, filter, search, cross-feature callbacks) | ✅ Complete |
| `PolicyCheckCard`, `PolicyDetailPanel` | ✅ Complete |
| Real backend (e.g., reading from policy register CSVs) | ❌ Missing |
| Tests | ⚠️ Minimal (only smoke test in `feature_policy_test.dart`) |
**Notes:** The policy feature is fake-data only. The policy register CSVs in `policies/register/` contain headers only with no data rows — the controlled document lifecycle is not being followed for the governance documents themselves.
---
#### `feature_finance` — [MISSING/PENDING]
| Artifact | Status |
| --------------------- | ------------------------------------- |
| Package scaffolding | ✅ Exists |
| Any domain model | ❌ Missing |
| Any application logic | ❌ Missing |
| Any data layer | ❌ Missing |
| Any presentation | ❌ Missing |
| Tests | ❌ Placeholder `Calculator` test only |
**Notes:** The barrel file (`feature_finance.dart`) contains only the Flutter template `Calculator` class. The Finance route in `kell_web` renders `FinancePlaceholderPage`. This is a pure stub.
---
#### `feature_mrp` — [MISSING/PENDING]
| Artifact | Status |
| --------------------- | ------------------------------------- |
| Package scaffolding | ✅ Exists |
| Any domain model | ❌ Missing |
| Any application logic | ❌ Missing |
| Any data layer | ❌ Missing |
| Any presentation | ❌ Missing |
| Tests | ❌ Placeholder `Calculator` test only |
**Notes:** Pure stub. Scope should be re-evaluated after Stage 12 (supplies tracking) to determine if MRP adds distinct value beyond custom orders + inventory.
---
#### `feature_social` — [MISSING/PENDING]
| Artifact | Status |
| --------------------- | ------------------------------------- |
| Package scaffolding | ✅ Exists |
| Any domain model | ❌ Missing |
| Any application logic | ❌ Missing |
| Any data layer | ❌ Missing |
| Any presentation | ❌ Missing |
| Tests | ❌ Placeholder `Calculator` test only |
**Notes:** Pure stub. Deferred until core platform stabilizes. Facebook, Instagram, and X (Twitter) API integrations are all listed as "Stub only" in the integration landscape.
---
### 2.3 Application Targets
#### `kell_web` — [COMPLETE] (for current feature scope)
| Artifact | Status |
| ----------------------------------------------------------------------------------- | ------------------- |
| `AppServices` (fake + wordpress factories) | ✅ Complete |
| `KcBootstrap` / `KcAppScope` wiring | ✅ Complete |
| `AppShell` with sidebar navigation (7 routes) | ✅ Complete |
| `AppRoutes` (dashboard, inventory, products, orders, finance, policy, integrations) | ✅ Complete |
| `DashboardPage` (8 KPI cards, quick actions, cross-feature navigation) | ✅ Complete |
| `ProductPublishingPage` (full feature surface) | ✅ Complete |
| `InventoryPage` (full feature surface) | ✅ Complete |
| `OrdersPage` (full feature surface) | ✅ Complete |
| `PolicyPage` (full feature surface) | ✅ Complete |
| `FinancePlaceholderPage` | ⚠️ Placeholder only |
| `IntegrationsPlaceholderPage` | ⚠️ Placeholder only |
| `square` / `multi` factory constructors in `AppServices` | ❌ Missing |
| Tests | ✅ 24/24 passing |
**Notes:** `AppServices` has no `square()` or `multi()` factory. Setting `KC_ENV=square` or `KC_ENV=multi` at runtime will fall through to `fake` mode silently because `KcServiceFactory` only has `createFake` and `createWordPress` callbacks. This is a critical gap for Square integration testing.
---
#### `kell_mobile` — [COMPLETE] (for current feature scope)
| Artifact | Status |
| ----------------------------------------------------------------------------------- | ---------------------- |
| `MobileAppServices` (fake + wordpress factories) | ✅ Complete |
| `KcBootstrap` / `KcAppScope` wiring | ✅ Complete |
| `MobileShell` (5-tab NavigationBar: Dashboard, Inventory, Orders, Publishing, More) | ✅ Complete |
| `DashboardPage` (mobile GridView layout) | ✅ Complete |
| `MobilePublishingPage` + `MobileProductDetailPage` | ✅ Complete |
| `MobileCreateProductPage` | ✅ Complete |
| `MobileInventoryPage` + `MobileInventoryDetailPage` | ✅ Complete |
| `MobileOrdersPage` + `MobileOrderDetailPage` | ✅ Complete |
| Haptic feedback, confirmation dialogs, SafeArea, 48dp touch targets | ✅ Complete |
| `FinancePlaceholderPage` | ⚠️ Placeholder only |
| `IntegrationsPlaceholderPage` | ⚠️ Placeholder only |
| `square` / `multi` factory constructors in `MobileAppServices` | ❌ Missing |
| Square POS / checkout flow | ❌ Missing (Stage 10C) |
| `MobileCheckoutPage` | ❌ Missing (Stage 10C) |
| Tests | ✅ 26/26 passing |
---
### 2.4 Architecture & Documentation
#### Architecture Documentation — [COMPLETE]
| Artifact | Status |
| ------------------------------------------------------------------------------ | ------------------------------- |
| 19 PlantUML C4 diagrams (context, containers, components, deployment, dynamic) | ✅ Complete |
| MkDocs documentation site (`mkdocs.yml`) | ✅ Complete |
| ADR index (7 retroactive entries) | ✅ Complete |
| Traceability index | ✅ Complete |
| Square not represented in architecture diagrams | ❌ Missing (noted in brief §17) |
| Multi-channel data model architecture | ❌ Missing (noted in brief §18) |
#### Policy Repository — [PARTIAL]
| Artifact | Status |
| ----------------------------------------------------------------------------- | ------------------------------------- |
| Policy templates (policy, procedure, standard, form, exception) | ✅ Complete |
| Active governance documents (KC-POL-GOV-001, KC-PRO-GOV-001) | ✅ Complete |
| Policy register CSVs (policy-register, document-control-log, review-calendar) | ❌ Empty (headers only, no data rows) |
| Exception register | ❌ Empty |
---
## 3. Technical Debt & Code Hygiene Assessment
### 3.1 Dead Code / Placeholder Stubs
| Location | Issue | Severity |
| ------------------------------------------------------------------ | -------------------------------------------------------------- | -------- |
| `feature_finance/lib/feature_finance.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
| `feature_mrp/lib/feature_mrp.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
| `feature_social/lib/feature_social.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
| `kell_web/lib/pages/finance_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_web/lib/pages/integrations_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_mobile/lib/pages/finance_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_mobile/lib/pages/integrations_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_web/lib/pages/dashboard_page.dart` (Recent Activity section) | `KcEmptyState` hardcoded — no real activity feed | LOW |
### 3.2 Missing Error Handling / Defensive Gaps
| Location | Issue | Severity |
| ---------------------------------------------- | ----------------------------------------------------------------------------- | -------- |
| `AppServices` / `MobileAppServices` | No `square()` or `multi()` factory — `KC_ENV=square` silently falls to fake | CRITICAL |
| `KcBootstrap` | Only validates WooCommerce credentials; Square credential validation absent | HIGH |
| `SquareCatalogRepository` | 6 methods throw `UnsupportedError` (categories, images) — callers must handle | HIGH |
| `integrations` — `RetryPolicy` / `RateLimiter` | Defined but not wired into `SquareApiClient` or `WooCommerceApiClient` | HIGH |
| `auth` package | Not imported or used by any app or feature package | HIGH |
| `data` package | Not imported or used by any app or feature package | MEDIUM |
| `InventoryController` | Not wired to `InventorySyncService` — sync cannot be triggered from UI | MEDIUM |
| `SquareCatalogSyncService` | No Riverpod/ChangeNotifier controller wrapper — cannot be triggered from UI | MEDIUM |
| `WooCommerceApiClient` | No retry logic on transient failures (5xx, network timeouts) | MEDIUM |
| `feature_policy` tests | Only a smoke test exists — no domain, controller, or widget tests | MEDIUM |
### 3.3 Hardcoded Values / Antipatterns
| Location | Issue | Severity |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------- |
| `SquareCatalogSyncService.syncCatalog()` | Uses `product.id` as both `localId` and `squareCatalogObjectId` — conflates Square catalog IDs with local product IDs | HIGH |
| `InventorySyncService._syncItem()` | `pushLocalStock` silently swallows push failures with `catch (_) {}` — no logging | MEDIUM |
| `kell_web/lib/pages/dashboard_page.dart` | Revenue card navigates to Finance route which is a placeholder | LOW |
| `policies/register/*.csv` | All register CSVs contain headers only — no data rows | MEDIUM |
| `feature_orders` package | Missing `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` | LOW |
### 3.4 Test Coverage Assessment
> Test counts re-verified 2026-09-25 by a full local run — **1,898 / 1,898 passing**.
> Coverage percentages are from the original 2026-07-25 audit and have not been
> re-measured; treat them as indicative only.
| Package | Tests | Coverage | Assessment |
| ------------------- | --------------- | -------- | ----------------------------------------------------- |
| `core` | 90 | ~85.7% | Good — composition + observability well covered |
| `design_system` | 51 | 100% | Excellent |
| `feature_wordpress` | 545 | ~84.7% | Good — broad coverage across all layers |
| `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present |
| `feature_orders` | 1,085 | Unknown | Excellent — custom order domain, UI, and tracking |
| `feature_policy` | 17 | Very low | **Gap** — thin; no controller/widget tests |
| `auth` | 42 | Unknown | Good for contracts and fakes |
| `data` | 63 | Unknown | Good for contracts and in-memory impls |
| `integrations` | 123 | Unknown | Good — API client and mapper tests present |
| `kell_web` | 81 | ~54.1% | **Gap** — low coverage; shell largely untested |
| `kell_mobile` | 109 | Unknown | Adequate — shell, page, checkout, and adapter tests |
| `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
**Key gaps:**
- `kell_web` at 54.1% line coverage — routing, shell, and navigation are largely untested
- `feature_policy` has no meaningful tests beyond a barrel smoke test
- No integration tests exist across any package boundary
- No end-to-end tests exist for any user workflow
- CI enforces no minimum coverage thresholds (visibility-only)
### 3.5 Architectural Antipatterns
> Status column added 2026-09-25. Three findings below were closed by Stages 10E and 16A.
| Issue | Description | Severity | Status |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------- |
| **No state management framework** | All state uses `ChangeNotifier` + `AnimatedBuilder`. As complexity grows (sync engine, multi-channel, offline queue), this pattern will become difficult to compose. No Riverpod, BLoC, or equivalent. | MEDIUM | Open |
| **No dependency injection** | Services are constructed directly in factory methods. No DI container. Makes testing harder as the app grows. | MEDIUM | Open |
| **`SquareCatalogSyncService` ID conflation** | `syncCatalog()` used `product.id` as the Square catalog object ID when it is really the local/WooCommerce ID. | HIGH | ✅ Resolved (10E) |
| **`AppServices` / `MobileAppServices` duplication** | Both files are nearly identical. A shared base class or factory could eliminate the duplication. | LOW | Open |
| **No logging infrastructure** | Structured logging landed in `core`: `KcLogger`, `KcLogLevel`, `KcLogRecord`, `KcLogSink`, `KcRedactor`. Repositories log failures with redacted context instead of swallowing them. | MEDIUM | ✅ Resolved (16A) |
| **No crash reporting** | `KcErrorReporter`, `KcObservability`, `KcBugReport`, and `KcBugReportService` capture crashes and user-submitted bug reports. No third-party SaaS dependency was introduced. | MEDIUM | ✅ Resolved (16A) |
---
## 4. Actionable Engineering Backlog
### Phase 1 — High-Priority / Blocking (Core Fixes & Missing Execution Paths)
These items block correct runtime behavior or represent data integrity risks.
#### P1.1 — Wire `square` and `multi` factory constructors into `AppServices` and `MobileAppServices`
**Why blocking:** Setting `KC_ENV=square` currently silently falls to fake mode. Square integration cannot be tested end-to-end without this.
**Scope:** Add `AppServices.square()` and `AppServices.multi()` factories using `SquareCatalogRepository` and `SquareInventoryRepository`. Mirror in `MobileAppServices`. Update `KcServiceFactory` to accept `createSquare` and `createMulti` callbacks. Update `KcBootstrap` to validate Square credentials.
**Branch:** `feat/square-catalog-sync-service-controller` (Stage 10E) should include this.
#### P1.2 — Fix `SquareCatalogSyncService.syncCatalog()` ID conflation
**Why blocking:** The current implementation uses `product.id` (a local/WooCommerce ID) as the `squareCatalogObjectId`. When `SquareCatalogRepository` is the source, `product.id` is the Square catalog object ID — but when `FakeProductPublishingRepository` or `WordPressProductPublishingRepository` is the source, `product.id` is a WooCommerce ID. The mapping will be incorrect in mixed-source scenarios.
**Scope:** The sync service needs to know which repository type it is talking to, or the `ProductDraft` model needs a `squareCatalogObjectId` field, or the sync service should accept the Square catalog object ID separately.
#### P1.3 — Merge Stage 10D (`feat/square-catalog-sync-service`)
**Why blocking:** The build tracker shows Stage 10D as "ready for merge" but not yet merged. The master brief baseline is Stage 10C. Stage 10E depends on 10D being on `main`.
**Action:** Merge `feat/square-catalog-sync-service` → `main` before starting Stage 10E.
#### P1.4 — `SquareSyncController` (Stage 10E)
**Why blocking:** `SquareCatalogSyncService` exists but has no UI trigger. Without a controller, sync cannot be initiated from the app.
**Scope:** `ChangeNotifier` wrapping `SquareCatalogSyncService`, exposing `syncAll()`, `syncCatalog()`, `syncInventory()`, `isSyncing`, `lastSyncedAt`, `lastResult`. Wire into `AppServices` / `MobileAppServices`.
**Branch:** `feat/square-catalog-sync-service-controller`
#### P1.5 — Add `square` / `multi` mode to `KcBootstrap` credential validation
**Why blocking:** `KcBootstrap` currently only validates WooCommerce credentials. Square mode will silently proceed with empty tokens.
**Scope:** Add `hasSquareConfig` check in bootstrap for `square` and `multi` environments.
---
### Phase 2 — Feature Completion & Integration
These items complete planned stages and wire existing contracts into production paths.
#### P2.1 — Square Mobile POS / Checkout (Stage 10C)
**Scope:** `PaymentService` abstraction, `PaymentResult` domain model, `MobileCheckoutPage` (product selection → cart → payment), Square Reader SDK integration for Android, cash recording, digital receipt, post-payment order creation and inventory deduction, `Sales` tab in mobile navigation.
**Dependencies:** Stage 10A ✅, Stage 10B ✅, Stage 10D ✅, P1.1, P1.4
#### P2.2 — Custom Orders Workflow (Stage 11) — ✅ RESOLVED
> Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
> Intake UI delivered on `feat/custom-order-ui` (Stage 11B, 2026-09-25).
> Tracking and fulfillment delivered on `feat/custom-order-tracking` (Stage 11C, 2026-09-25).
**Delivered (11A):** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object (money as integer cents), `FulfillmentStatus` pipeline with domain-enforced legal transitions (`quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled` and a `qualityCheck → inProduction` rework path), extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` / `advanceFulfillmentStatus()` on `OrdersRepository`, implemented in both `FakeOrdersRepository` (seeded across every stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data). 423 new tests.
**Delivered (11B):** `CreateCustomOrder` / `RecordDeposit` use cases, `CustomOrderFormController` (field-keyed validation, integer-cent money parsing, injectable clock and ID generator), `CustomOrderFormPage`, `CustomOrderConfirmationPage` with a deposit receipt that carries no payment identifier, `FulfillmentStatusChip`, `showReferenceImageDialog()`, an optional `onCreateCustomOrder` action on `OrdersPage`, the `/orders/custom` web route, and `MobileCustomOrderPage` behind an orders FAB. 146 new tests.
**Delivered (11C):** `FulfillmentEvent` (append-only transition record with operator notes and a malformed-line-tolerant wire format), `MaterialUsage` + `MaterialConsumptionPort` + `MaterialDeductionResult`, `Order.fulfillmentHistory` / `materialUsage` and their derived accessors, `advanceFulfillmentStatus()` extended with note and timestamp, `recordMaterialUsage()`, `AdvanceFulfillment` / `CompleteCustomOrder` use cases, `CustomOrderTrackingController`, `CustomOrderTrackingPage` / `CustomOrderTrackingCard` / `CustomOrderTrackingDetail` / `FulfillmentTimeline` / `showAdvanceStageDialog`, an optional `onTrackCustomOrders` action on `OrdersPage`, the `/orders/tracking` web route, `MobileCustomOrderTrackingPage`, and `InventoryMaterialConsumptionAdapter` in both composition layers. 329 new tests.
**Architecture note:** inventory deduction crosses the feature boundary through an **outbound port**, not a package dependency. `feature_orders` declares `MaterialConsumptionPort`; each app's composition layer supplies the adapter over its own `InventoryRepository`. `feature_orders` still has no dependency on `feature_inventory`. `CompleteCustomOrder` advances the order before deducting, so a rejected transition can never decrement stock.
**Deferred (not blocking):** status-change notification triggers need an outbound messaging channel the platform does not have yet — `FulfillmentEvent` is the hook when one lands. Catalogue product-base selection and platform camera / file-picker capture remain with the media pipeline. A material-usage editing UI belongs with Stage 12B's supplies workflows.
**Dependencies:** Stage 9A ✅, Stage 11A ✅, Stage 11B ✅, Stage 11C ✅
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
**Scope:** `ItemType` enum, `InventoryAdjustment` audit log, `Supplier` domain model, `PurchaseOrder` domain model, supply receiving workflow, material usage workflow, low-stock alert system, `SuppliesPage` UI.
**Dependencies:** Stage 9B ✅
#### P2.4 — Multi-Channel Inventory Sync Engine (Stage 12C)
**Scope:** `InventorySyncEngine` (poll/webhook-triggered, conflict resolution, batch sync), `ChannelStockMapping`, `WooCommerceStockSync`, `SquareStockSync`, sync status indicators in inventory UI, manual sync trigger.
**Dependencies:** P2.3, Stage 10B ✅
#### P2.5 — WooCommerce Orders Integration wired into `AppServices`
**Current state:** `WooCommerceOrdersRepository` exists but `AppServices.wordpress()` still uses `FakeOrdersRepository`.
**Scope:** Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` and `MobileAppServices.wordpress()`.
#### P2.6 — WooCommerce Inventory Integration wired into `AppServices`
**Current state:** `WooCommerceInventoryRepository` exists but `AppServices.wordpress()` still uses `FakeInventoryRepository`.
**Scope:** Wire `WooCommerceInventoryRepository` into `AppServices.wordpress()` and `MobileAppServices.wordpress()`.
#### P2.7 — Unified Order View (Stage 13B)
**Scope:** `OrderChannel` enum, `UnifiedOrdersController` aggregating WooCommerce + Square + manual orders, unified orders page with channel indicator.
**Dependencies:** P2.1, Stage 9A ✅
#### P2.8 — Offline Data Cache & Sales Queue (Stage 14)
**Scope:** SQLite-backed `LocalCache<T>` implementation (using `sqflite` or `drift`), SQLite-backed `SyncQueue`, offline detection, "Prepare for Market" pre-sync action, auto-sync on reconnect, conflict handling.
**Dependencies:** `data` package contracts ✅
#### P2.9 — `feature_finance` Activation (Stage 15)
**Scope:** Sales reporting (by channel, product, time period), inventory valuation, financial dashboard (revenue vs. COGS, gross margin, market event profitability).
**Dependencies:** P2.1, Stage 9A ✅, P2.3
#### P2.10 — Populate Policy Register CSVs
**Scope:** Add data rows to `policies/register/policy-register.csv`, `document-control-log.csv`, `review-calendar.csv`, and `exception-register.csv` for the 2 active governance documents.
**Dependencies:** None — can be done immediately.
---
### Phase 3 — Defensive Engineering, Logging & Test Harness Expansion
These items harden the platform for production deployment.
#### P3.1 — Wire `RetryPolicy` and `RateLimiter` into API clients
**Scope:** Apply `ExponentialBackoffRetryPolicy` to `SquareApiClient` and `WooCommerceApiClient` for 5xx and network errors. Apply `TokenBucketRateLimiter` to Square API calls (Square enforces rate limits).
#### P3.2 — Implement `SecureCredentialStore` (Stage 8C remainder)
**Scope:** `flutter_secure_storage`-backed `CredentialStore` implementation. First-run setup wizard for entering API credentials. Connection testing UI.
#### P3.3 — Auth guard wired into app shell
**Scope:** Login screen, `AuthService` wired into `AppServices`, route guard preventing unauthenticated access.
**Dependencies:** P3.2
#### P3.4 — Structured logging infrastructure
**Scope:** Add a logging abstraction (e.g., `package:logging`) with structured output. Log all API calls, sync operations, errors, and state transitions. Replace silent `catch (_) {}` blocks with logged failures.
#### P3.5 — Crash reporting integration (Stage 16A)
**Scope:** Sentry or Firebase Crashlytics integration. Health check dashboard showing integration status per channel.
#### P3.6 — Expand `feature_policy` test coverage
**Scope:** Add controller tests, widget tests for `PolicyPage`, `PolicyCheckCard`, `PolicyDetailPanel`. Target >80% line coverage.
#### P3.7 — Expand `kell_web` test coverage from 54.1%
**Scope:** Add routing tests, shell navigation tests, `AppServices` factory tests. Target >75% line coverage.
#### ~~P3.8 — Add `feature_orders` standard scaffolding files~~ ✅ RESOLVED
> Delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
**Scope:** Add `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` to `feature_orders` to match all other packages.
`.gitignore` already existed; `.metadata`, `CHANGELOG.md`, `LICENSE`, and `README.md` were added. The README documents the fulfillment pipeline, the WooCommerce meta mapping, and the observability/privacy rules rather than carrying the default template placeholders.
#### P3.9 — Update architecture diagrams for Square and multi-channel
**Scope:** Add Square to `system-landscape.puml`, `enterprise-integration-orchestration-architecture.puml`, `context-kellcreations-platform.puml`. Create `dynamic-market-sale-checkout.puml`. Create `enterprise-multi-channel-architecture.puml`.
#### P3.10 — Implement `ChannelAdapter` for WooCommerce and Square
**Scope:** Concrete `WooCommerceChannelAdapter` and `SquareChannelAdapter` implementing the `ChannelAdapter` contract from `integrations`. This enables the multi-channel sync engine (Stage 13A) to operate against a channel-agnostic interface.
#### P3.11 — Enforce minimum test coverage thresholds in CI
**Scope:** Update `flutter-test.yml` to fail the build if any package drops below a defined threshold (e.g., 75% line coverage). Currently coverage is visibility-only.
#### P3.12 — Evaluate and activate `feature_mrp`
**Scope:** After Stage 12 completion, assess whether MRP adds distinct value (production scheduling, work orders, bill of materials) beyond custom orders + inventory. Activate or formally retire the package.
---
## 5. Summary Scorecard
> **Re-baselined 2026-09-25** against `main` after Stages 10C–10F, 11A, and 16A
> merged. Test counts are the verified per-package totals from a full local run
> (**1,898 / 1,898 passing**); `dart analyze --fatal-infos` is clean across all
> 11 packages and apps.
| Subsystem | Status | Completeness | Tests | Blocking Issues |
| ------------------- | ----------- | ------------ | ----- | --------------------------------------------------------------------- |
| `core` | ✅ COMPLETE | 100% | 90 | None — square/multi bootstrap validation landed (Stage 10E) |
| `design_system` | ✅ COMPLETE | 100% | 51 | None |
| `auth` | ⚠️ PARTIAL | 40% | 42 | No production impl; `SecureCredentialStore` TBD; not wired into shell |
| `data` | ⚠️ PARTIAL | 35% | 63 | No SQLite impl; not wired into any feature |
| `integrations` | ⚠️ PARTIAL | 70% | 123 | RetryPolicy/RateLimiter not wired; `ChannelAdapter` not implemented |
| `feature_wordpress` | ✅ COMPLETE | 100% | 545 | None — `SquareSyncController` landed (Stage 10E) |
| `feature_inventory` | ⚠️ PARTIAL | 65% | 167 | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `feature_orders` | ✅ COMPLETE | 100% | 1,085 | None — Stage 11 complete (domain 11A, intake UI 11B, tracking 11C) |
| `feature_policy` | ⚠️ PARTIAL | 50% | 17 | Fake-only; minimal tests; policy register empty |
| `feature_finance` | ❌ MISSING | 0% | 1 | Pure stub |
| `feature_mrp` | ❌ MISSING | 0% | 1 | Pure stub |
| `feature_social` | ❌ MISSING | 0% | 1 | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 85% | 81 | Finance placeholder remains; custom order tracking route landed (11C) |
| `kell_mobile` | ⚠️ PARTIAL | 85% | 109 | Finance placeholder remains; custom order tracking landed (11C) |
| Architecture docs | ⚠️ PARTIAL | 80% | — | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | — | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | — | No coverage thresholds enforced |
### Overall Platform Readiness
| Dimension | Assessment |
| ---------------------------------- | ----------------------------------------------------------- |
| **WooCommerce catalog management** | ✅ Production-ready |
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
| **Square catalog sync** | ✅ Service + controller + UI panel; ID bug fixed (10E/10F) |
| **Square inventory sync** | ✅ Repository wired via `square()` / `multi()` factories |
| **Square payments / POS** | ✅ Mobile checkout flow implemented (Stage 10C) |
| **Custom orders** | ⚠️ Domain, data, and intake UI done (11A/11B); tracking open |
| **Observability / error logging** | ✅ `KcLogger`, `KcErrorReporter`, bug reports (Stage 16A) |
| **Supplies tracking** | ❌ Not implemented |
| **Multi-channel sync engine** | ❌ Not implemented |
| **Offline capability** | ❌ Not implemented |
| **Authentication** | ❌ Contracts only; not wired |
| **Financial reporting** | ❌ Not implemented |
| **Social media management** | ❌ Not implemented |
---
_Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25._

View File

@ -6,12 +6,6 @@ This diagram shows the major internal components of the Inventory Application.
This view breaks the Inventory Application into its main user interface, service, reporting, adjustment, and data access responsibilities. It provides the first detailed component-level view inside a Kell Creations application container.
## Diagram Source
The source for this diagram is maintained as architecture code in:
`architecture/workspace/components-inventory.puml`
## Diagram
![Inventory Components](../../images/components-inventory.svg)
@ -36,4 +30,4 @@ The source for this diagram is maintained as architecture code in:
## Notes
This component view will later be expanded to align with the detailed design of inventory control, material tracking, stock adjustments, exception handling, and business reporting across the Kell Creations platform.
This component view will later be expanded to align with the detailed design of inventory control, material tracking, stock adjustments, exception handling, and business reporting across the Kell Creations platform.

View File

@ -14,7 +14,7 @@ The source for this diagram is maintained as architecture code in:
## Diagram
![Enterprise Data Architecture](../../images/enterprise-data-architecture.svg)
![Enterprise Shared Services](../../images/containers-enterprise-services.svg)
## Included Data Services and Stores
@ -41,4 +41,4 @@ The source for this diagram is maintained as architecture code in:
## Notes
This data architecture establishes the enterprise model for shared master data, transactional records, controlled reference data, documentation content, and auditable events. Future application and component designs should align to this model and avoid creating unmanaged, application-specific data silos.
This data architecture establishes the enterprise model for shared master data, transactional records, controlled reference data, documentation content, and auditable events. Future application and component designs should align to this model and avoid creating unmanaged, application-specific data silos.

View File

@ -6,12 +6,6 @@ This diagram shows the major application and shared-service containers that make
This view breaks the platform into its primary internal applications, shared services, and controlled content repositories. It is intended to show how the platform is logically organized before moving deeper into component-level and deployment-level views.
## Diagram Source
The source for this diagram is maintained as architecture code in:
`architecture/workspace/containers-platform.puml`
## Diagram
![Platform Containers](../../images/containers-platform.svg)
@ -45,4 +39,4 @@ The source for this diagram is maintained as architecture code in:
## Notes
This container view will later be expanded with component diagrams for each major application and service area. It also establishes the architectural basis for documenting shared authentication, shared data, workflow orchestration, and controlled policy management across the entire Kell Creations platform.
This container view will later be expanded with component diagrams for each major application and service area. It also establishes the architectural basis for documenting shared authentication, shared data, workflow orchestration, and controlled policy management across the entire Kell Creations platform.

View File

@ -1,84 +1,3 @@
# ADR Index
Architecture Decision Records will be maintained here.
## Current State
No formal ADRs have been recorded yet. The following decisions have been identified through project analysis as candidates for retroactive ADR documentation.
!!! info "Last analyzed: 2026-05-22"
## Recommended ADRs
The following significant architecture decisions are already in effect across the platform. Recording them as formal ADRs would establish an auditable decision history and provide context for future changes.
### ADR-001 — Use C4 model with PlantUML as architecture-as-code standard
- **Status:** Accepted (in practice)
- **Context:** The platform needs a consistent architecture documentation approach that supports version control and CI/CD rendering
- **Decision:** Use the C4 model (landscape, context, container, component) with PlantUML sources maintained in `architecture/workspace/`
- **Consequences:** All 19 architecture diagrams follow this pattern; diagrams are rendered automatically via CI/CD; local preview requires PlantUML server access
### ADR-002 — Use Flutter monorepo with shared packages for cross-platform applications
- **Status:** Accepted (in practice)
- **Context:** The platform needs to support both web and Android applications with shared business logic
- **Decision:** Use a Flutter monorepo structure under `kell_creations_apps/` with shared packages (`core`, `design_system`, `feature_*`) and platform-specific app targets (`kell_web`, `kell_mobile`)
- **Consequences:** Business logic is shared; domain/application/data/presentation layers are separated per package; each app target composes its own shell and routing
### ADR-003 — Use `--dart-define` for runtime environment selection
- **Status:** Accepted (in practice)
- **Context:** The platform needs to switch between fake and real backends without code changes
- **Decision:** Use `--dart-define` keys (`KC_ENV`, `KC_WC_SITE_URL`, etc.) to select runtime environment at build time
- **Consequences:** No settings UI needed; credentials are never hardcoded; `KcBootstrap` handles fallback to fake mode when WP credentials are missing
### ADR-004 — Use MkDocs Material with Forgejo CI/CD for documentation publishing
- **Status:** Accepted (in practice)
- **Context:** The platform needs a docs-as-code publishing pipeline
- **Decision:** Use MkDocs Material as the documentation framework, published via Forgejo Actions with dedicated runners on the Ubuntu documentation host
- **Consequences:** Documentation is version-controlled; publish and validate workflows are separated by branch; the published site at `docs.kellsupport.com` is automatically updated on `main` branch pushes
### ADR-005 — Use abstract service composition pattern in `core` for cross-platform app shells
- **Status:** Accepted (in practice)
- **Context:** `kell_web` and `kell_mobile` need to share composition logic without circular dependencies
- **Decision:** Extract abstract composition types (`KcAppConfig`, `KcAppServices`, `KcServiceFactory`, `KcBootstrap`, `KcAppScope`) into the `core` package; each app target provides concrete implementations
- **Consequences:** Composition contract is enforced; circular dependencies avoided; `kell_web` retains backward-compatible typedefs; documented in `kell_creations_apps/docs/composition-strategy.md`
### ADR-006 — Maintain policy repository with controlled lifecycle and YAML front matter metadata
- **Status:** Accepted (in practice)
- **Context:** The business needs formal governance for policies, procedures, standards, and exceptions
- **Decision:** Use a Git-based policy repository with lifecycle directories (`drafts/`, `review/`, `active/`, `retired/`), YAML front matter metadata, CSV registers, and evidence retention
- **Consequences:** All controlled documents follow a defined lifecycle; metadata requirements prevent informal policy creation; registers and evidence support audit readiness
### ADR-007 — Use Forgejo Actions with dedicated runners for CI/CD pipelines
- **Status:** Accepted (in practice)
- **Context:** The platform needs automated validation and publishing for both documentation and Flutter applications
- **Decision:** Use Forgejo Actions with two runner types: a Docker runner for containerized workloads and a host runner for direct filesystem access (publishing, rsync)
- **Consequences:** Four workflows operational (`publish-docs`, `validate-docs`, `flutter-analyze`, `flutter-test`); runner tokens must be managed securely; host runner limited to narrowly defined workflows
## ADR Template
When recording new ADRs, use the following structure:
```markdown
### ADR-NNN — Title
- **Status:** Proposed | Accepted | Deprecated | Superseded
- **Context:** What is the issue that we're seeing that motivates this decision?
- **Decision:** What is the change that we're proposing and/or doing?
- **Consequences:** What becomes easier or more difficult to do because of this change?
```
## Maintenance
New ADRs should be added when:
- A significant technology, framework, or tool choice is made
- A structural or organizational pattern is established or changed
- A decision constrains future development options
- A previous ADR is superseded or deprecated

View File

@ -1,120 +1,3 @@
# Architecture Overview
This section documents the Kell Creations platform using C4 modeling, PlantUML, and supporting technical documentation.
## Architecture Model
The platform architecture follows the C4 model with five distinct view types:
| Level | View | Purpose |
| ----- | ---------------- | --------------------------------------------------------------- |
| 1 | System Landscape | Highest-level view of the platform and its external environment |
| 2 | System Context | Platform boundary with users and external systems |
| 3 | Container | Major application and shared-service containers |
| 4 | Component | Internal structure of each application container |
| — | Dynamic | Cross-application workflow and sequence diagrams |
| — | Deployment | Runtime topology and infrastructure |
## Architecture Artifacts
All architecture diagrams are maintained as code using PlantUML (C4-PlantUML) in `architecture/workspace/`. Documentation pages in `docs/architecture/` provide narrative context, included elements, and notes for each diagram.
### Current inventory
| Category | Count | Files |
| ---------------- | ------ | ---------------------------------------------------------------------------------------------------- |
| System Landscape | 1 | `system-landscape.puml` |
| System Context | 1 | `context-kellcreations-platform.puml` |
| Container views | 6 | Platform containers, enterprise services, data, identity & access, integration, audit |
| Component views | 5 | Inventory, MRP, WordPress management, social media, financial analysis |
| Dynamic views | 5 | Order-to-fulfillment, product publishing, social campaign, inventory-to-production, policy lifecycle |
| Deployment views | 1 | Production deployment |
| **Total** | **19** | All maintained as PlantUML in `architecture/workspace/` |
## Analysis Findings
!!! info "Last analyzed: 2026-05-22"
The following findings were identified through a full review of the architecture documentation, PlantUML sources, and cross-referencing with the application codebase.
### Confirmed strengths
1. **Complete C4 coverage** — All four C4 levels are documented with consistent structure across views
2. **Architecture as code** — All 19 diagrams are maintained as PlantUML source, rendered via CI/CD pipeline
3. **Traceability index** — A comprehensive cross-reference maps business domains to architecture artifacts, workflows, and governance documents
4. **Enterprise service backbone** — Shared services (authentication, data, integration, audit, notifications) are architecturally defined and consistently referenced across all application component views
5. **CI/CD integration** — PlantUML rendering is integrated into the publish and validate workflows with automatic SVG generation
### Issues identified
#### 1. Enterprise Data Architecture — wrong diagram reference
**Severity:** High
The file `docs/architecture/containers/enterprise-data-architecture.md` references the wrong SVG image:
```markdown
![Enterprise Shared Services](../../images/containers-enterprise-services.svg)
```
This should reference `enterprise-data-architecture.svg` instead. The corresponding PlantUML source file exists (`enterprise-data-architecture.puml`) but the documentation page displays the enterprise services diagram instead of the data architecture diagram.
**Recommendation:** Fix the image reference to point to the correct SVG.
#### 2. Missing diagram source references
**Severity:** Low
The following documentation pages do not include a "Diagram Source" section pointing to their PlantUML file:
- `docs/architecture/system-landscape.md` — missing reference to `architecture/workspace/system-landscape.puml`
- `docs/architecture/containers/platform-containers.md` — missing reference to `architecture/workspace/containers-platform.puml`
- `docs/architecture/components/inventory.md` — missing reference to `architecture/workspace/components-inventory.puml`
All other architecture documentation pages include this reference consistently. These three should be updated for consistency.
#### 3. ADR index is empty
**Severity:** Medium
The Architecture Decision Record (ADR) index exists at `docs/architecture/decisions/index.md` but contains no entries. Several significant architectural decisions have already been made and should be captured:
**Recommended initial ADRs:**
| ADR | Decision |
| ------- | ----------------------------------------------------------------------------------- |
| ADR-001 | Use C4 model with PlantUML as architecture-as-code standard |
| ADR-002 | Use Flutter monorepo with shared packages for cross-platform applications |
| ADR-003 | Use `--dart-define` for runtime environment selection (fake vs. real backends) |
| ADR-004 | Use MkDocs Material with Forgejo CI/CD for documentation publishing |
| ADR-005 | Use abstract service composition pattern in `core` for cross-platform app shells |
| ADR-006 | Maintain policy repository with controlled lifecycle and YAML front matter metadata |
| ADR-007 | Use Forgejo Actions with dedicated runners for CI/CD pipelines |
#### 4. Architecture-to-implementation gap
**Severity:** Medium
The architecture documentation describes rich capabilities across all five business domains, but actual implementation maturity varies significantly. Only `feature_wordpress` has substantial implementation. This creates a risk that architecture documentation may be perceived as describing current state rather than target state.
**Recommendation:** Add a clear "implementation status" or "maturity" indicator to each component view documentation page, or maintain a single cross-reference (already partially addressed by the feature maturity matrix in the master development brief).
#### 5. No architecture for the documentation platform itself
**Severity:** Low
The deployment view covers the documentation publishing infrastructure, but there is no component-level architecture for the documentation platform itself (MkDocs, PlantUML server, Forgejo runners, published site). Given the CI/CD complexity already documented in the operations section, a lightweight component view could help operational clarity.
#### 6. Images directory not committed to Git
**Severity:** Informational
The `docs/images/` directory does not exist in the repository. SVG images are generated at CI/CD time by the publish and validate workflows. This is by design — the workflows create the directory, render PlantUML to SVG, and copy files before the MkDocs build. This is architecturally sound but means local MkDocs builds without running the PlantUML render step will have broken image references.
**Recommendation:** Document this behavior in the architecture workflow documentation so new contributors understand that `docs/images/` is a build artifact, not a committed directory.
## Related Pages
- [Architecture Principles](principles.md)
- [Traceability Index](traceability-index.md)
- [ADR Index](decisions/index.md)

View File

@ -8,12 +8,6 @@ It identifies the primary business users, the central Kell Creations platform, a
This view is intended to provide a management-level understanding of the platform ecosystem. It is the starting point for more detailed context, container, component, deployment, and workflow diagrams.
## Diagram Source
The source for this diagram is maintained as architecture code in:
`architecture/workspace/system-landscape.puml`
## Diagram
![System Landscape](../images/system-landscape.svg)
@ -39,4 +33,4 @@ This landscape view will evolve as the internal applications are broken out into
- Craft Manufacturing / MRP
- Financial Analysis
- Transaction Database
- Shared services for authentication, data, API orchestration, and common UI patterns
- Shared services for authentication, data, API orchestration, and common UI patterns

View File

@ -25,49 +25,49 @@ Use this page to answer questions such as:
## 1. Enterprise Architecture Traceability
| Enterprise View | File | Supports Domains |
| --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
| Platform Context | `docs/architecture/context/platform.md` | All domains |
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
| Enterprise Shared Services | `docs/architecture/containers/enterprise-services.md` | All domains |
| Enterprise Data Architecture | `docs/architecture/containers/enterprise-data-architecture.md` | Inventory, MRP, WordPress, Social Media, Financial Analysis, Policy Repository |
| Enterprise Identity & Access Architecture | `docs/architecture/containers/enterprise-identity-access-architecture.md` | All application and governance domains |
| Enterprise View | File | Supports Domains |
|---|---|---|
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
| Platform Context | `docs/architecture/context/platform.md` | All domains |
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
| Enterprise Shared Services | `docs/architecture/containers/enterprise-services.md` | All domains |
| Enterprise Data Architecture | `docs/architecture/containers/enterprise-data-architecture.md` | Inventory, MRP, WordPress, Social Media, Financial Analysis, Policy Repository |
| Enterprise Identity & Access Architecture | `docs/architecture/containers/enterprise-identity-access-architecture.md` | All application and governance domains |
| Enterprise Integration & Orchestration Architecture | `docs/architecture/containers/enterprise-integration-orchestration-architecture.md` | WordPress, Social Media, MRP, Inventory, Financial Analysis, Policy Repository |
| Enterprise Audit, Logging & Compliance Architecture | `docs/architecture/containers/enterprise-audit-logging-compliance-architecture.md` | All application and governance domains |
| Deployment Architecture | `docs/architecture/deployment/production.md` | All domains |
| Enterprise Audit, Logging & Compliance Architecture | `docs/architecture/containers/enterprise-audit-logging-compliance-architecture.md` | All application and governance domains |
| Deployment Architecture | `docs/architecture/deployment/production.md` | All domains |
---
## 2. Application Component Traceability
| Application Domain | Component View | File | Related Enterprise Views |
| ------------------------- | ------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------ |
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| Craft Manufacturing / MRP | Craft Manufacturing / MRP Components | `docs/architecture/components/mrp.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Social Media Management | Social Media Management Components | `docs/architecture/components/social-media-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Financial Analysis | Financial Analysis Components | `docs/architecture/components/financial-analysis.md` | Shared Services, Data, Identity & Access, Audit |
| Application Domain | Component View | File | Related Enterprise Views |
|---|---|---|---|
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| Craft Manufacturing / MRP | Craft Manufacturing / MRP Components | `docs/architecture/components/mrp.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Social Media Management | Social Media Management Components | `docs/architecture/components/social-media-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Financial Analysis | Financial Analysis Components | `docs/architecture/components/financial-analysis.md` | Shared Services, Data, Identity & Access, Audit |
---
## 3. Dynamic Workflow Traceability
| Workflow | File | Primary Domains | Related Component Views |
| --------------------------------------- | --------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------ |
| Order to Fulfillment | `docs/architecture/dynamic/order-to-fulfillment.md` | WordPress, Inventory, MRP, Financial Analysis | Inventory, MRP, WordPress, Financial Analysis |
| Product Publishing Workflow | `docs/architecture/dynamic/product-publishing.md` | WordPress Management, Shared Data | WordPress Management |
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
| Inventory-to-Production Workflow | `docs/architecture/dynamic/inventory-to-production.md` | Inventory, MRP | Inventory, MRP |
| Policy Approval and Retirement Workflow | `docs/architecture/dynamic/policy-approval-retirement.md` | Governance, Policy Repository | Enterprise Audit, Identity & Access, Policy Repository |
| Workflow | File | Primary Domains | Related Component Views |
|---|---|---|---|
| Order to Fulfillment | `docs/architecture/dynamic/order-to-fulfillment.md` | WordPress, Inventory, MRP, Financial Analysis | Inventory, MRP, WordPress, Financial Analysis |
| Product Publishing Workflow | `docs/architecture/dynamic/product-publishing.md` | WordPress Management, Shared Data | WordPress Management |
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
| Inventory-to-Production Workflow | `docs/architecture/dynamic/inventory-to-production.md` | Inventory, MRP | Inventory, MRP |
| Policy Approval and Retirement Workflow | `docs/architecture/dynamic/policy-approval-retirement.md` | Governance, Policy Repository | Enterprise Audit, Identity & Access, Policy Repository |
---
## 4. Governance Document Traceability
| Governance Document | File | Governs Domains | Related Workflows / Views |
| -------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
| Document Control Policy | `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
| Governance Document | File | Governs Domains | Related Workflows / Views |
|---|---|---|---|
| Document Control Policy | `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
| Policy Review and Retirement Procedure | `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
---
@ -75,7 +75,6 @@ Use this page to answer questions such as:
## 5. Domain-to-Artifact Mapping
### Inventory
- Component View:
- `docs/architecture/components/inventory.md`
- Dynamic Workflows:
@ -89,7 +88,6 @@ Use this page to answer questions such as:
- Integration & Orchestration
### Craft Manufacturing / MRP
- Component View:
- `docs/architecture/components/mrp.md`
- Dynamic Workflows:
@ -103,7 +101,6 @@ Use this page to answer questions such as:
- Integration & Orchestration
### WordPress Management
- Component View:
- `docs/architecture/components/wordpress-management.md`
- Dynamic Workflows:
@ -118,7 +115,6 @@ Use this page to answer questions such as:
- Deployment
### Social Media Management
- Component View:
- `docs/architecture/components/social-media-management.md`
- Dynamic Workflows:
@ -131,7 +127,6 @@ Use this page to answer questions such as:
- Audit & Compliance
### Financial Analysis
- Component View:
- `docs/architecture/components/financial-analysis.md`
- Dynamic Workflows:
@ -143,7 +138,6 @@ Use this page to answer questions such as:
- Audit & Compliance
### Governance / Policy Repository
- Governance Documents:
- `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md`
- `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md`
@ -161,28 +155,20 @@ Use this page to answer questions such as:
The following areas are strong candidates for future documentation:
!!! info "Last analyzed: 2026-05-22"
### Additional component views
- Policy Repository internal components
- Notification Service internal components
- API Orchestrator internal components
- Shared Data Repository internal components
- Documentation Platform internal components (MkDocs, PlantUML server, Forgejo runners)
### Additional dynamic workflows
- Financial close / reporting workflow
- Website change management workflow
- Exception handling workflow
- Marketing campaign analytics workflow
- Product image / media synchronization workflow
- Local development setup workflow
- Incident response workflow
### Additional governance documents
- Access Control Policy
- Social Media Publishing Standard
- Product Publishing Procedure
@ -192,29 +178,6 @@ The following areas are strong candidates for future documentation:
- Website Change Management Procedure
- Exception Management Standard
### Architecture decisions
- No formal ADRs exist; 7 candidate ADRs identified — see [ADR Index](decisions/index.md)
### Standards documents
- No formal standards documents exist; 10 candidate standards identified — see [Standards Overview](../standards/index.md)
### Operational procedures
- No operational runbooks exist; 5 candidate procedures identified — see [Operations Overview](../operations/index.md)
### Integration documentation
- Only WooCommerce integration is implemented; 5 additional integrations architecturally defined — see [Integrations Overview](../integrations/index.md)
### Documentation quality issues
- Enterprise Data Architecture page references wrong SVG diagram — **fixed 2026-05-22**
- Three architecture pages were missing "Diagram Source" references — **fixed 2026-05-22**
- Policy register CSVs are empty (header only) despite 2 active governance documents
- `feature_orders` package is missing standard scaffolding files (`.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md`) present in all other packages
---
## 7. Maintenance Rules
@ -229,4 +192,4 @@ This traceability index should be updated whenever any of the following occur:
## Summary
This traceability index is the navigation and governance bridge between architecture, workflows, business domains, and controlled documentation in the Kell Creations platform repository.
This traceability index is the navigation and governance bridge between architecture, workflows, business domains, and controlled documentation in the Kell Creations platform repository.

View File

@ -1,73 +1,3 @@
# Brand Standards
This section maps the Kell Creations brand guide into reusable documentation and design standards.
## Current State
No formal brand documentation has been published yet. Brand-related design elements exist in the `design_system` Flutter package but have not been documented as brand standards.
## Existing Brand Elements
### Design system implementation
The `design_system` package (`kell_creations_apps/packages/design_system/`) contains the following brand-relevant components:
| Element | Implementation | Notes |
| -------------- | --------------- | ------------------------------------------------------- |
| Color palette | `KcColors` | Brand colors defined as Flutter constants |
| Spacing system | `KcSpacing` | Consistent spacing scale |
| Typography | `KcTypography` | Full Material 3 text style hierarchy |
| Theme | `KcTheme` | Composed theme applying colors, typography, and spacing |
| Breakpoints | `KcBreakpoints` | Responsive layout breakpoints for web and mobile |
### Reusable brand widgets
| Widget | Purpose |
| ----------------- | ------------------------------------ |
| `KcCard` | Standard content card |
| `KcStatusChip` | Status indicator chip |
| `KcSectionHeader` | Section heading with optional action |
| `KcSummaryCard` | Dashboard summary card |
| `KcEmptyState` | Empty/placeholder state display |
| `KcLoadingState` | Loading indicator |
| `KcErrorState` | Error display with optional retry |
## Recommendations
!!! info "Last analyzed: 2026-05-22"
### 1. Document color palette and usage guidelines
**Priority:** Medium
The `KcColors` class defines the color palette, but there is no documentation specifying:
- Primary, secondary, and accent color usage
- Accessibility contrast requirements
- Color usage in different contexts (backgrounds, text, borders, status indicators)
### 2. Document typography scale and usage
**Priority:** Medium
`KcTypography` defines the full type hierarchy but lacks documentation on:
- When to use each text style (headings, body, captions, labels)
- Font family and weight guidelines
- Line height and letter spacing rationale
### 3. Create a visual brand style guide
**Priority:** Low
A visual reference showing the brand elements in use would help maintain consistency across web and mobile platforms. This could be a dedicated MkDocs page with color swatches, typography samples, and widget examples.
### 4. Document logo and asset guidelines
**Priority:** Low
No logo or visual asset documentation exists. If brand logos, icons, or imagery guidelines exist, they should be documented here.
## Relationship to Design System
The `design_system` package is the programmatic implementation of brand standards. Changes to brand standards should be reflected in the design system, and vice versa. The design system has full test coverage (41 tests, 100% line coverage as of 2026-05-22).

View File

@ -1,98 +0,0 @@
<!--
MD011 (no-reversed-links) is disabled for this file.
This PRD uses `(descriptive text)[cite: n]` source-citation markers, which are
syntactically indistinguishable from a reversed Markdown link. They are not
links and must not be "corrected" — running `markdownlint --fix` on this file
will silently rewrite them into malformed links and destroy the citations.
-->
<!-- markdownlint-disable MD011 -->
# Product Requirement Document (PRD): Kell Creations Operations Platform
## 1. Executive Summary & Vision
The **Kell Creations Operations Platform** is a cross-platform (Web & Android) multi-channel commerce management system. It unifies online storefront operations (WooCommerce), in-person craft market point-of-sale (Square), custom made-to-order requests, and raw supply/finished inventory management into a single operational interface[cite: 3].
## 2. Platform Architecture & Stack
- **Framework:** Flutter (Dart 3.11+)
- **Target Platforms:** Windows Desktop / Web (`kell_web`), Android (`kell_mobile`)[cite: 1, 3]
- **Monorepo Architecture:** Feature-first modular packages (`feature_wordpress`, `feature_inventory`, `feature_orders`, `feature_policy`, `auth`, `data`, `integrations`, `design_system`, `core`)[cite: 1, 3]
- **State & Data Flow:** Unidirectional flow using `ChangeNotifier` controllers and repository interfaces with mock (`FAKE`) and production implementation modes.
---
## 3. Core Functional Requirements
### 3.1 Multi-Channel Catalog Management
- **WooCommerce Storefront:**
- View, filter, search, and sort product drafts and published items.
- Single-field and controlled inline edits (Name, Price, Description, Category).
- Controlled status transitions (`unpublished` -> `draft`, `pendingReview` -> `draft`/`published`)[cite: 3].
- Full Product Creation pipeline (`CreateProductDialog` on Web, `MobileCreateProductPage` on Android)[cite: 2, 3].
- Product category CRUD and management workspace[cite: 2, 3].
- Controlled bulk actions (Bulk move-to-draft, bulk publish, bulk submit for review)[cite: 2, 3].
- **Square Catalog Integration:**
- Map WooCommerce items to Square Catalog API objects (`CatalogObject`)[cite: 2, 3].
- Support cross-channel ID mapping (`ProductIdMapping`) to prevent duplicate listings[cite: 3].
### 3.2 Mobile Point-of-Sale (POS) & Market Sales
- Android-optimized UI using Material 3 `NavigationBar` and `IndexedStack`[cite: 2, 3].
- In-person checkout workflow via Square API integration[cite: 2, 3].
- Support cash recording and card processing via Square Reader SDK[cite: 3].
- Automatic inventory deduction and digital receipt generation upon payment completion[cite: 3].
### 3.3 Custom Orders Workflow
- **Specification Tracking:** Record custom order specs (material, color, size, dimensions, reference images)[cite: 3].
- **Financial Deposit Management:** Track deposit payments, deposit dates, remaining balance due, and final settlement[cite: 3].
- **Fulfillment Pipeline:** Status tracking (`quoted` -> `depositReceived` -> `inProduction` -> `qualityCheck` -> `readyForPickup` -> `completed`)[cite: 3].
### 3.4 Inventory & Supply Chain Operations
- **Dual-Type Tracking:** Separate raw materials/supplies (inputs) from finished goods (catalog items)[cite: 3].
- **Stock Adjustments:** Delta adjustments (+/-) and explicit quantity setting with reason logging[cite: 2, 3].
- **Bidirectional Stock Sync:** Real-time and batch stock synchronization across WooCommerce stock levels and Square inventory counts[cite: 2, 3].
- **Reorder Logic:** Automated low-stock indicators based on item reorder points[cite: 2, 3].
### 3.5 Infrastructure & Shared Services
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
- **Observability & Support:** Structured logging (`KcLogger`) with five severities and a bounded in-memory evidence buffer; global uncaught-error capture (`KcErrorReporter`) across framework, platform, and zone channels; operator bug reporting (`KcBugReportService`) that attaches the recent log tail; mandatory credential redaction (`KcRedactor`) on all logged context so API tokens and secrets can never reach a log or a submitted report.
---
## 4. Non-Functional Requirements & Metrics
- **Code Coverage Visibility:** Maintain and expand line coverage visibility reported via Forgejo CI (`flutter test --coverage`)[cite: 2, 3].
- **Touch Optimization:** Minimum 48×48dp touch target enforcement across all interactive controls on mobile surfaces[cite: 2, 3].
- **Tactile & Visual Feedback:** Haptic feedback (`mediumImpact` for state changes, `lightImpact` for field edits) and explicit SnackBar feedback[cite: 2, 3].
- **Zero Structural Regressions:** Mandatory static analysis passing (`dart analyze --fatal-infos`) on every commit[cite: 2, 3].
---
## 5. Milestone & Feature Release Schedule
| Stage | Feature Focus | Status |
| :------------- | :------------------------------------------------------------------- | :------------------------------- |
| **Stages 1–3** | Controlled Product Editing, UX Hardening & List Efficiency | ✅ Merged[cite: 2, 3] |
| **Stage 4** | Shared Design System, Core Abstractions, CI/CD & Coverage Visibility | ✅ Merged[cite: 2, 3] |
| **Stage 5** | Android Shell Bootstrap & Publishing Surface | ✅ Merged[cite: 2, 3] |
| **Stage 6** | Android Touch Target Hardening & Mobile Surfaces (Orders/Inventory) | ✅ Merged[cite: 2, 3] |
| **Stage 7** | Bulk Status Workflows (Move to Draft, Publish, Submit for Review) | ✅ Merged[cite: 2, 3] |
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
| **Stage 10B** | Square Catalog & Inventory Sync | ✅ Merged |
| **Stage 10C** | Square Mobile POS & In-Person Payments | ✅ Merged |
| **Stage 10E** | `SquareSyncController`, `square()`/`multi()` factories, ID fix | ✅ Merged |
| **Stage 10F** | Square Sync UI (`SquareSyncStatusPanel`, Integrations pages) | ✅ Merged |
| **Stage 16A** | **Error Logging, Crash Capture & Bug Reporting** | ✅ Merged |
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | 🔄 **Next Stage** |
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
| **Stage 16B** | Secure Credential Storage & Auth Guard | ⏳ Planned — Production blocker |
| **Stage 16C** | Retry/Rate Limiting & Remote Diagnostics | ⏳ Planned — Staging blocker |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,3 @@
# Kell Creations Platform
This site is the central documentation portal for Kell Creations applications, services, architecture, policies, and operating processes.
## Platform Health Summary
This section provides an at-a-glance assessment of the platform's documentation, architecture, application, and governance maturity. It is maintained through periodic full-project analysis.
!!! info "Last reviewed: 2026-05-22"
### Documentation maturity
| Area | Status | Notes |
| ----------------------- | ------------------- | ------------------------------------------------------------------- |
| Architecture (C4 model) | ✅ Comprehensive | All C4 levels documented with PlantUML sources |
| Architecture decisions | ⚠️ Empty | ADR index exists but no decisions recorded |
| Governance policies | ✅ Foundation set | 2 active governance documents, templates complete |
| Operations runbooks | ⚠️ Minimal | CI/CD and architecture workflow documented; no operational runbooks |
| Standards | ⚠️ Placeholder only | Index page exists with no content |
| Integrations | ⚠️ Placeholder only | Index page exists with no content |
| Brand | ⚠️ Placeholder only | Index page exists with no content |
| Development planning | ✅ Comprehensive | Master brief, tracker, composition strategy documented |
### Application maturity
| Package | Implementation | Tests | Production readiness |
| ------------------- | ---------------- | ------- | -------------------- |
| `feature_wordpress` | ✅ Complete | 294 | Production-ready |
| `feature_inventory` | ✅ Fake-only MVP | Minimal | Fake-only |
| `feature_orders` | ✅ Fake-only MVP | Some | Fake-only |
| `feature_policy` | ✅ Fake-only MVP | Minimal | Fake-only |
| `feature_finance` | ❌ Stub | None | Scaffolded only |
| `feature_mrp` | ❌ Stub | None | Scaffolded only |
| `feature_social` | ❌ Stub | None | Scaffolded only |
| `auth` | ❌ Stub | None | Scaffolded only |
| `data` | ❌ Stub | None | Scaffolded only |
| `integrations` | ❌ Stub | None | Scaffolded only |
### Key recommendations
The following high-priority recommendations were identified through full-project analysis. Detailed findings are documented in [Architecture Overview](architecture/index.md), [Standards Overview](standards/index.md), [Operations Overview](operations/index.md), and the [Master Development Brief](development/master_development_brief.md).
1. **Fix broken diagram image reference** — The Enterprise Data Architecture page references the wrong SVG file
2. **Populate empty documentation sections** — Standards, Integrations, and Brand sections need initial content
3. **Record architecture decisions** — Start capturing ADRs for key decisions already made
4. **Populate policy registers** — Active governance documents are not reflected in register CSVs
5. **Expand CI/CD validation** — Add Markdown linting, link checking, and PlantUML validation
6. **Add missing diagram source references** — Some architecture docs are missing PlantUML source paths
7. **Plan infrastructure package activation** — `auth`, `data`, and `integrations` packages need activation roadmaps

View File

@ -1,71 +1,3 @@
# Integrations Overview
This section documents WordPress, n8n, mail, and social media integrations for the Kell Creations platform.
## Current State
No formal integration documentation has been published yet. Integration patterns are described at the architecture level in the [Enterprise Integration & Orchestration Architecture](../architecture/containers/enterprise-integration-orchestration-architecture.md) and implemented in the `feature_wordpress` package.
## Existing Integrations
### Implemented
| Integration | Package | Protocol | Status |
| -------------------- | ------------------- | --------- | ------------------- |
| WooCommerce REST API | `feature_wordpress` | HTTP/REST | ✅ Production-ready |
### Architecturally defined but not implemented
| Integration | Architecture Reference | Notes |
| --------------- | ----------------------------------- | ----------------------------------------- |
| Facebook API | Social Media Management Components | `feature_social` is stub only |
| Instagram API | Social Media Management Components | `feature_social` is stub only |
| X (Twitter) API | Social Media Management Components | `feature_social` is stub only |
| n8n Workflows | Enterprise Integration Architecture | No workflow automation integration exists |
| Mail Server | Enterprise Integration Architecture | No programmatic mail integration exists |
## Recommendations
!!! info "Last analyzed: 2026-05-22"
### 1. Document the WooCommerce integration pattern
**Priority:** High
The WooCommerce integration in `feature_wordpress` is the most mature integration and establishes patterns that future integrations should follow (API client abstraction, repository interface, fake/real runtime selection). This pattern should be formally documented as a reference for future integration work.
### 2. Define integration contracts before implementation
**Priority:** Medium
The `integrations` package exists as a stub. Before implementing new integrations, define shared abstractions for:
- API client lifecycle (creation, authentication, error handling)
- Rate limiting and retry patterns
- Response mapping conventions
- Integration health checking
### 3. Document n8n workflow inventory
**Priority:** Low
n8n is referenced in the architecture as the workflow automation platform. If any n8n workflows are currently active, they should be documented here with their triggers, actions, and dependencies.
### 4. Plan social media API integration
**Priority:** Low
Social media platform APIs (Facebook, Instagram, X) are referenced in the architecture but require individual integration documentation covering:
- API authentication requirements
- Rate limits and quotas
- Content format requirements per platform
- Platform-specific publishing rules
## Relationship to Architecture
Integration documentation should align with:
- [Enterprise Integration & Orchestration Architecture](../architecture/containers/enterprise-integration-orchestration-architecture.md)
- [Enterprise Shared Services](../architecture/containers/enterprise-services.md)
- Component views for each application domain
This section documents WordPress, n8n, mail, and social media integrations.

View File

@ -1,109 +1,3 @@
# Operations Overview
This section contains operational runbooks, CI/CD documentation, and business procedures for the Kell Creations platform.
## Current Operational Documentation
| Document | Purpose | Status |
| ------------------------------------------------- | ---------------------------------------------------- | ---------------- |
| [CI/CD Workflow](cicd-workflow.md) | Defines the documentation publishing pipeline | ✅ Comprehensive |
| [Architecture Workflow](architecture-workflow.md) | Defines the diagram authoring and publishing process | ✅ Complete |
| [Release Checklist](release-checklist.md) | Per-environment release gates and sign-off record | ✅ Complete |
## Analysis Findings
!!! info "Last analyzed: 2026-05-22"
### Confirmed strengths
1. **CI/CD documentation is thorough** — The CI/CD workflow document covers platforms, runner architecture, branch behavior, troubleshooting, permissions, and security considerations
2. **Architecture workflow is well-defined** — Clear step-by-step process for creating and publishing diagrams
3. **Four Forgejo Actions workflows are operational** — `publish-docs.yml`, `validate-docs.yml`, `flutter-analyze.yml`, `flutter-test.yml`
### Gaps and recommendations
#### 1. No operational runbooks
**Priority:** Medium
No runbooks exist for common operational tasks such as:
- Server health checks and restart procedures
- Forgejo runner maintenance and token rotation
- PlantUML server maintenance
- MkDocs container updates
- Backup and recovery procedures
- SSL certificate renewal
- DNS and reverse proxy configuration
**Recommendation:** Create lightweight runbooks for the most critical operations first. Suggested initial candidates:
| Candidate | Description |
| ------------------------------ | -------------------------------------------------------------------------------- |
| Runner maintenance runbook | How to check, restart, re-register, and rotate tokens for Forgejo runners |
| Documentation host maintenance | Docker container updates, published site integrity checks, disk space monitoring |
| Incident response procedure | What to do when the docs site, Git, or runners are down |
#### 2. No monitoring or alerting documentation
**Priority:** Medium
No documentation exists for how to detect or respond to:
- CI/CD pipeline failures
- Documentation site downtime
- Runner service failures
- Disk space or resource exhaustion
**Recommendation:** Document current monitoring capabilities (even if manual) and identify candidates for automated alerting.
#### 3. Architecture workflow is incomplete
**Priority:** Low
The architecture workflow document at `docs/operations/architecture-workflow.md` ends at step 4 (validate repository state) without covering:
- Commit and push procedures
- CI/CD pipeline verification
- Published site verification
- Diagram review process
**Recommendation:** Complete the remaining workflow steps to match the level of detail in the CI/CD workflow document.
#### 4. Local development setup not documented
**Priority:** Low
No documentation covers how to set up a local development environment for:
- MkDocs local preview (including the PlantUML render step)
- Flutter development environment setup
- Forgejo runner local testing
**Recommendation:** Add a developer setup guide, particularly noting that `docs/images/` is a CI/CD build artifact and local MkDocs builds require manual PlantUML rendering.
#### 5. CI/CD validation could be expanded
**Priority:** Low
The CI/CD workflow document itself identifies future enhancements that remain unimplemented:
- Broken-link validation
- Markdown linting integration
- PlantUML diagram validation
- Required document metadata checks
- Notification hooks for failed publishes
**Recommendation:** Prioritize Markdown linting and link checking as the highest-value additions to the validation pipeline.
## Recommended Procedures
The following operational procedures are candidates for formal documentation using the procedure template at `policies/templates/procedure-template.md`:
| Candidate ID | Title | Priority |
| -------------- | ---------------------------------------- | -------- |
| KC-PRO-IT-001 | Forgejo Runner Maintenance Procedure | Medium |
| KC-PRO-IT-002 | Documentation Host Maintenance Procedure | Medium |
| KC-PRO-OPS-001 | Incident Response Procedure | Medium |
| KC-PRO-IT-003 | Local Development Setup Procedure | Low |
| KC-PRO-OPS-002 | Backup and Recovery Procedure | Low |
This section contains operational runbooks and business procedures.

View File

@ -1,304 +0,0 @@
# Release Checklist
**Owner:** Release Committee
**Applies to:** `kell_web` (Flutter Web / Windows Desktop) and `kell_mobile` (Flutter Android)
**Last reviewed:** 2026-08-29
**Review cycle:** Every release; document reviewed at least quarterly
---
## Purpose
This document defines the **mandatory gates** a build must pass before it is
promoted into each target environment. Each environment inherits every gate from
the environment before it, then adds its own.
Promotion is **sequential**: Development → Test/QA → Staging → Production. A
build may not skip an environment.
---
## 1. Environment Matrix
| Environment | Target | `KC_ENV` | Square mode | Data | Audience | Approver |
| --------------- | --------------------------------- | --------------------- | ------------ | ----------------------- | ------------- | ----------------- |
| **Development** | Local workstation | `fake` | n/a | In-memory fakes | Engineer | Self |
| **Test / QA** | CI runner + internal test devices | `wordpress` | n/a | WooCommerce staging | Engineer + QA | Engineering lead |
| **Staging** | Pre-production host + test device | `multi` | `sandbox` | Staging WC + Square SBX | QA + business | Release committee |
| **Production** | Live web host + Play Store track | `wordpress` / `multi` | `production` | Live WC + Square live | Operators | Release committee |
> **Rule:** `KC_SQUARE_ENVIRONMENT=production` is permitted **only** in the
> Production environment. Any lower environment using a live Square token is an
> immediate stop-ship defect.
---
## 2. Universal Gates (all environments)
These must pass before any promotion.
- [ ] `dart analyze --fatal-infos` is clean for all 11 packages and both apps
- [ ] Full test suite passes — current baseline **1,475 / 1,475**
- [ ] No test is skipped, `@Skip`-ed, or commented out without a linked issue
- [ ] `docs/development/build_execution_tracker.md` has a slice entry for the change
- [ ] `docs/development/master_development_brief.md` reflects the new baseline
- [ ] No secrets committed — credentials supplied only via `--dart-define`
- [ ] No `TODO`/`FIXME` introduced without a linked tracker item
Commands:
```bash
cd kell_creations_apps
./tools/run_all_tests.sh --analyze
```
---
## 3. Development Environment
**Purpose:** Fast inner-loop work against in-memory fakes. No external calls.
### Build
```bash
flutter run -d chrome --dart-define=KC_ENV=fake # kell_web
flutter run -d android --dart-define=KC_ENV=fake # kell_mobile
```
### Gates
- [ ] Universal gates pass
- [ ] Feature branch is cut from current `main`
- [ ] Slice is vertical and reviewable (domain → application → data → presentation)
- [ ] New public APIs carry doc comments
- [ ] Environment badge renders `FAKE`
- [ ] "Report a problem" action opens the dialog and submits successfully
- [ ] No unexpected `ERROR`/`FATAL` records in the console during a smoke pass
### Exit criteria
Analyze clean, tests green, tracker updated. Open PR into `main`.
---
## 4. Test / QA Environment
**Purpose:** Validate real WooCommerce integration and cross-platform parity.
### Build
```bash
flutter build web --dart-define=KC_ENV=wordpress \
--dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \
--dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
--dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
flutter build apk --debug --dart-define=KC_ENV=wordpress \
--dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \
--dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
--dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
```
### Gates
- [ ] All Development gates pass
- [ ] Forgejo `Flutter Test` workflow green
- [ ] Forgejo `Flutter Analyze` workflow green
- [ ] Coverage reported in the CI summary; no unexplained decrease
- [ ] Credentials point at **staging** WooCommerce — never production
- [ ] Environment badge renders `WP`
- [ ] Missing-credential path verified: app falls back to `FAKE` **and logs a warning**
### Functional verification (WooCommerce)
- [ ] Product list loads, filters, searches, and sorts
- [ ] Single-field edits persist (name, price, description, category)
- [ ] Status transitions behave (`unpublished` → `draft`, `pendingReview` → `published`)
- [ ] Product creation completes on web (`CreateProductDialog`) and mobile (`MobileCreateProductPage`)
- [ ] Category CRUD works
- [ ] Bulk actions (move-to-draft, publish, submit-for-review) work
- [ ] API failures surface a user-visible error — never a silent failure
### Observability verification
- [ ] Startup record written with the effective environment
- [ ] A forced API failure produces an `ERROR` record with source and context
- [ ] Bug report submission attaches the recent log tail
- [ ] **Credential redaction confirmed** — no token, key, or secret appears in any log or report
### Mobile-specific
- [ ] Touch targets meet the 48×48dp minimum
- [ ] Haptics fire (`mediumImpact` state changes, `lightImpact` field edits)
- [ ] Layout is correct on a notch/gesture-bar device
- [ ] Back navigation behaves throughout
### Exit criteria
CI green, functional pass recorded, no open Critical or High defects.
---
## 5. Staging Environment
**Purpose:** Production rehearsal with both channels live in sandbox mode.
### Build
```bash
flutter build web --release --dart-define=KC_ENV=multi \
--dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \
--dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
--dart-define=KC_WC_CONSUMER_SECRET=cs_xxx \
--dart-define=KC_SQUARE_ACCESS_TOKEN=EAAAl_sandbox_xxx \
--dart-define=KC_SQUARE_LOCATION_ID=L_xxx \
--dart-define=KC_SQUARE_ENVIRONMENT=sandbox
```
The Android build uses the same defines with `flutter build apk --release`.
### Gates
- [ ] All Test/QA gates pass
- [ ] Built in **release** mode (not debug/profile)
- [ ] `KC_SQUARE_ENVIRONMENT=sandbox` — confirmed, not `production`
- [ ] Square token is a **sandbox** token
- [ ] Environment badge renders `MULTI`
- [ ] Incomplete-credential path falls back to `FAKE` with a logged warning
### Multi-channel verification
- [ ] Square catalog sync completes; `SquareSyncStatusPanel` reports the result
- [ ] `ProductIdMapping` records are created correctly — **no duplicate listings**
- [ ] Already-synced products reuse their existing mapping (ID conflation regression check)
- [ ] Square inventory counts load and reconcile
- [ ] Partial sync failure surfaces per-item `SyncError` detail
- [ ] Integrations page reflects live sync state on web and mobile
### Mobile POS verification (sandbox)
- [ ] Product selection → cart → payment flow completes
- [ ] Cash payment records correctly
- [ ] Card payment completes against Square sandbox
- [ ] Receipt renders with the correct total
- [ ] Payment failure is handled without data loss
### Non-functional
- [ ] Release build starts within an acceptable time on a representative device
- [ ] No memory growth across an extended session
- [ ] Web build loads over the target network profile
- [ ] Offline/interrupted-network behaviour is understood and documented
> **Known limitation:** Offline caching and the sales queue (Stage 14) are **not
> implemented**. Network loss during a market sale is currently unrecoverable.
> This must be stated in release notes for any field use.
### Exit criteria
Business sign-off recorded, no open Critical/High defects, rollback plan agreed.
---
## 6. Production Environment
**Purpose:** Live operator use against real customers and real money.
### Pre-flight
- [ ] All Staging gates pass
- [ ] Release committee approval recorded
- [ ] Release notes drafted, including known limitations
- [ ] Rollback plan documented and the previous artifact retained
- [ ] Version bumped in both `pubspec.yaml` files
- [ ] `AppServices.appVersion` and `MobileAppServices.appVersion` match `pubspec.yaml`
- [ ] Release tagged in Git
### Credential and safety gates
- [ ] Production WooCommerce credentials sourced from the secret store — **not** the repo
- [ ] `KC_SQUARE_ENVIRONMENT=production` set deliberately
- [ ] Square production token scoped to the minimum required permissions
- [ ] Square location ID matches the intended physical location
- [ ] A staging credential is confirmed absent from the production build
- [ ] Credential redaction verified once more in the production artifact
### Build
```bash
flutter build web --release --dart-define=KC_ENV=multi \
--dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \
--dart-define=KC_WC_CONSUMER_KEY=$WC_KEY \
--dart-define=KC_WC_CONSUMER_SECRET=$WC_SECRET \
--dart-define=KC_SQUARE_ACCESS_TOKEN=$SQUARE_TOKEN \
--dart-define=KC_SQUARE_LOCATION_ID=$SQUARE_LOCATION \
--dart-define=KC_SQUARE_ENVIRONMENT=production
```
The Android release build uses the same defines with `flutter build appbundle --release`.
### Post-deployment verification
- [ ] App loads and the environment badge is correct
- [ ] A read-only operation succeeds (product list loads)
- [ ] A single low-risk write succeeds and is confirmed in WooCommerce admin
- [ ] Square sync runs once and mappings are correct
- [ ] **One low-value real transaction** processed end-to-end and reconciled in Square
- [ ] Bug reporting works from the production build
- [ ] No `ERROR`/`FATAL` records during the verification pass
### Monitoring window
- [ ] Observed for the first operating session
- [ ] Bug report queue reviewed before sign-off
- [ ] Operators briefed on "Report a problem" and known limitations
### Rollback triggers
Roll back immediately on any of:
- Incorrect pricing, inventory, or payment amounts
- Duplicate product listings from a sync defect
- Failed or double-charged payments
- Credential leakage in any log or report
- Any Critical defect without a workaround
---
## 7. Release Sign-Off Record
| Field | Value |
| ------------------ | ------------------- |
| Release version | |
| Git tag / commit | |
| Target environment | |
| Test result | \_\_ / 1475 passing |
| Analyze result | |
| Known limitations | |
| Approver | |
| Date | |
---
## 8. Open Release Blockers
Carried from `PROJECT_STATE_AUDIT.md`. Each must be closed or explicitly accepted
as a documented limitation before the affected environment is used in earnest.
| ID | Item | Blocks |
| ----- | ------------------------------------------------------------- | -------------------------- |
| P2.5 | `WooCommerceOrdersRepository` not wired into `AppServices` | Test/QA orders workflow |
| P2.6 | `WooCommerceInventoryRepository` not wired into `AppServices` | Test/QA inventory workflow |
| P2.8 | Offline cache and sales queue not implemented (Stage 14) | Production field use |
| P3.2 | `SecureCredentialStore` not implemented | Production |
| P3.3 | Auth guard not wired into either shell | Production |
| P3.1 | `RetryPolicy` / `RateLimiter` not wired into API clients | Staging, Production |
| P3.6 | `feature_policy` has only a smoke test | Test/QA |
| P2.10 | Policy register CSVs contain headers only | Governance compliance |
> **Committee position:** With Stage 16A complete, the platform now has error
> logging and bug reporting. It is **approved for Development and Test/QA**.
> **Staging is conditionally approved** pending P3.1. **Production is not
> approved** until P3.2, P3.3, and P2.8 are closed — the platform currently has
> no authentication guard, no secure credential storage, and no offline
> resilience for in-person sales.

View File

@ -25,49 +25,6 @@ The initial governance set includes:
- Document Control Policy
- Policy Review and Retirement Procedure
## Analysis Findings
!!! info "Last analyzed: 2026-05-22"
### Confirmed strengths
1. **Well-structured repository** — The `policies/` directory follows a clear lifecycle model with properly separated stages (drafts, review, active, retired)
2. **Comprehensive templates** — Five document type templates are available (policy, procedure, standard, form, exception) with consistent YAML front matter
3. **Domain categorization** — All lifecycle directories include subdirectories for 9 business domains (ecommerce, finance, governance, IT, manufacturing, marketing, operations, privacy, security)
4. **Register structure** — Four CSV registers exist for policy tracking (policy register, control log, review calendar, exception register)
5. **Active governance documents** — 2 foundational governance documents are published and active with complete metadata
### Issues identified
#### 1. Policy register CSVs are empty
**Severity:** High
All four register CSVs contain headers only with no data rows, despite 2 active governance documents existing:
- `policy-register.csv` — should have entries for `KC-POL-GOV-001` and `KC-PRO-GOV-001`
- `document-control-log.csv` — should record the initial publication of both documents
- `review-calendar.csv` — should show next review dates (both set to 2027-04-03)
- `exception-register.csv` — may remain empty if no exceptions exist
**Recommendation:** Populate the registers immediately. The Document Control Policy (`KC-POL-GOV-001`) itself requires that active documents be referenced in the document register and control log.
#### 2. No evidence records exist
**Severity:** Medium
The `evidence/` directory has subdirectories for approvals, exceptions, retirement records, and reviews, but all are empty. The 2 active governance documents were approved on 2026-04-03 but no approval evidence is stored.
**Recommendation:** Create initial approval evidence records for the existing governance documents.
#### 3. Governance documents are only domain with active content
**Severity:** Informational
All other domain directories (ecommerce, finance, IT, manufacturing, marketing, operations, privacy, security) are empty across all lifecycle stages. This is expected given the platform's maturity but should be tracked for future planning.
**Recommendation:** Prioritize standards and procedures identified in the [Standards Overview](../standards/index.md) and [Operations Overview](../operations/index.md) as the next candidates for policy repository content.
## Notes
This section documents the governance layer that supports controlled business operations for Kell Creations. Future additions should include standards, procedures, guidelines, forms, and exception records across governance, operations, ecommerce, marketing, manufacturing, finance, IT, security, and privacy.
This section documents the governance layer that supports controlled business operations for Kell Creations. Future additions should include standards, procedures, guidelines, forms, and exception records across governance, operations, ecommerce, marketing, manufacturing, finance, IT, security, and privacy.

View File

@ -1,50 +1,3 @@
# Standards Overview
This section contains technical, documentation, and business standards.
## Current State
No formal standards documents have been published yet. The following standards are applied implicitly through existing architecture, operations, and development documentation but have not been formalized as controlled standards documents.
## Recommended Standards
The following standards were identified through project analysis as candidates for formal documentation. Each aligns to the policy repository naming convention (`KC-STD-<DOMAIN>-<NUMBER>`) and can be created using the standard template at `policies/templates/standard-template.md`.
### Documentation standards
| Candidate ID | Title | Priority | Notes |
| -------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------- |
| KC-STD-GOV-001 | Documentation Naming and Metadata Standard | High | Formalize the YAML front matter requirements already defined in the policy repository README |
| KC-STD-GOV-002 | Architecture Documentation Standard | Medium | Formalize the C4 diagram documentation pattern (purpose, diagram source, diagram, elements, notes) already in use |
| KC-STD-IT-001 | Markdown Authoring Standard | Low | Formalize linting rules (`.markdownlint.json`), heading conventions, and link patterns |
### Development standards
| Candidate ID | Title | Priority | Notes |
| ------------- | ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| KC-STD-IT-002 | Flutter Package Structure Standard | High | Formalize the domain/application/data/presentation layer pattern used across feature packages |
| KC-STD-IT-003 | Test Coverage Standard | Medium | Formalize baseline coverage expectations and reporting; currently visibility-only with no thresholds |
| KC-STD-IT-004 | Git Branching and Merge Standard | Medium | Formalize the `feat/` branch-per-slice model and PR requirements documented in the development brief |
| KC-STD-IT-005 | CI/CD Workflow Standard | Low | Formalize workflow naming, trigger patterns, and runner label conventions |
### Business standards
| Candidate ID | Title | Priority | Notes |
| -------------- | -------------------------------- | -------- | ------------------------------------------------------ |
| KC-STD-MKT-001 | Social Media Publishing Standard | Low | Referenced in the traceability index as a coverage gap |
| KC-STD-ECM-001 | Product Publishing Standard | Low | Referenced in the traceability index as a coverage gap |
| KC-STD-OPS-001 | Inventory Adjustment Standard | Low | Referenced in the traceability index as a coverage gap |
## Prioritization
Standards should be prioritized based on:
1. **Risk reduction** — Does this standard prevent inconsistency or errors?
2. **Existing practice** — Is the standard already followed informally?
3. **Scope of impact** — How many areas of the platform does it affect?
The documentation and development standards are recommended first because they codify practices already in use and reduce the risk of drift as the platform grows.
## Relationship to Policies and Procedures
Standards define measurable requirements and constraints. They are governed by the Document Control Policy (`KC-POL-GOV-001`) and follow the same controlled lifecycle (draft → review → active → retired).

0
draft
View File

Binary file not shown.

View File

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable"
project_type: app
@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
# User provided section

View File

@ -7,15 +7,6 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
linter:

View File

@ -1,5 +1,6 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}

View File

@ -1,6 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=true
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=true

View File

@ -1,22 +0,0 @@
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(),
);
}
}

View File

@ -1,104 +0,0 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
/// Adapts an [InventoryRepository] to the orders layer's
/// [MaterialConsumptionPort].
///
/// This adapter lives in the app's composition layer on purpose.
/// `feature_orders` and `feature_inventory` are sibling features; neither may
/// depend on the other, or the custom order pipeline becomes coupled to a
/// stock implementation. The orders layer declares the narrow port it needs
/// and the app decides which repository satisfies it — the same arrangement
/// already used for cross-feature navigation callbacks.
///
/// Deliberately duplicated from `kell_web`'s composition layer rather than
/// shared. The two apps have no common composition package, and the existing
/// `get_dashboard_summary.dart` follows the same convention — a shared
/// package for two twenty-line adapters would couple the app targets for no
/// benefit.
///
/// ## Failure behaviour
///
/// Deduction is **tolerant by contract**: one material failing must not abort
/// the rest, and nothing here may throw. A missing SKU, an unsuccessful
/// adjustment, and an unexpected exception all become entries in
/// [MaterialDeductionResult.failures] so the operator sees exactly which
/// material could not be decremented while the commission still completes.
class InventoryMaterialConsumptionAdapter implements MaterialConsumptionPort {
final InventoryRepository _inventory;
/// Structured logger for deduction failures.
///
/// Defaults to [KcLogger.silent] so tests need not supply one. Log context
/// carries SKUs, order IDs, and quantities only — never customer identity,
/// money amounts, or payment identifiers.
final KcLogger logger;
InventoryMaterialConsumptionAdapter({required InventoryRepository inventory, KcLogger? logger})
: _inventory = inventory,
logger = (logger ?? KcLogger.silent()).forSource(_logSource);
static const String _logSource = 'InventoryMaterialConsumptionAdapter';
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
final deducted = <MaterialUsage>[];
final failures = <String, String>{};
for (final usage in usages) {
if (!usage.isValid) {
failures[usage.sku] = 'Invalid material record.';
continue;
}
try {
final item = await _inventory.getItemBySku(usage.sku);
if (item == null) {
failures[usage.sku] = 'No inventory item with this SKU.';
logger.warning(
'Material deduction skipped: SKU not in inventory.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
);
continue;
}
final result = await _inventory.adjustQuantity(
item.id,
-usage.quantity,
'Custom order $orderId materials',
);
if (result.success) {
deducted.add(usage);
} else {
failures[usage.sku] = result.errorMessage ?? 'Adjustment rejected.';
logger.warning(
'Material deduction rejected by inventory.',
context: {
'operation': 'deductMaterials',
'orderId': orderId,
'sku': usage.sku,
'quantity': usage.quantity,
},
);
}
} catch (e, stackTrace) {
// Tolerant by contract: record the failure and keep going so one bad
// SKU cannot abandon the remaining deductions.
failures[usage.sku] = 'Inventory error: $e';
logger.error(
'Unexpected error while deducting a material.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
error: e,
stackTrace: stackTrace,
);
}
}
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}

View File

@ -1,233 +0,0 @@
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';
import 'package:integrations/integrations.dart';
import 'inventory_material_consumption_adapter.dart';
/// Default catalogue items used in fake/wordpress mode where no Square catalog
/// is available. These seed the [MobileCheckoutPage] product list.
const _fakeCatalogue = [
OrderItem(productName: 'Macramé Wall Hanging', sku: 'MWH-001', quantity: 1, unitPrice: 45.00),
OrderItem(productName: 'Beaded Bracelet', sku: 'BB-002', quantity: 1, unitPrice: 12.00),
OrderItem(productName: 'Ceramic Mug', sku: 'CM-003', quantity: 1, unitPrice: 18.00),
OrderItem(productName: 'Soy Candle — Lavender', sku: 'SC-004', quantity: 1, unitPrice: 14.00),
OrderItem(productName: 'Knitted Scarf', sku: 'KS-005', quantity: 1, unitPrice: 32.00),
];
/// 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;
/// The Square sync controller, or `null` when Square is not configured.
///
/// Present only in `square` and `multi` environments.
final SquareSyncController? squareSyncController;
/// Payment service used by the mobile POS checkout flow.
final PaymentService paymentService;
/// Catalogue items available for sale in the [MobileCheckoutPage].
///
/// In fake/wordpress mode this is seeded with [_fakeCatalogue].
/// In square/multi mode this could be populated from the Square catalog.
final List<OrderItem> checkoutCatalogue;
/// The observability stack: structured logger, in-memory evidence buffer,
/// and the bug report service backing the shell's "Report a problem" action.
final KcObservability observability;
/// Deducts raw materials from stock when a custom order completes.
///
/// Built here rather than inside `feature_orders` because the orders and
/// inventory features must stay independent — the orders layer declares the
/// port, the composition layer supplies the adapter.
late final MaterialConsumptionPort materialConsumptionPort = InventoryMaterialConsumptionAdapter(
inventory: inventoryRepository,
logger: observability.logger,
);
MobileAppServices({
required this.inventoryRepository,
required this.ordersRepository,
required this.policyRepository,
required this.productPublishingRepository,
required this.paymentService,
this.squareSyncController,
this.checkoutCatalogue = _fakeCatalogue,
KcObservability? observability,
}) : observability = observability ?? _defaultObservability('FAKE');
/// The application version reported in bug reports.
///
/// Kept in sync with `version:` in `pubspec.yaml`.
static const String appVersion = '1.0.0+1';
/// Convenience accessor for the shared structured logger.
KcLogger get logger => observability.logger;
/// Convenience accessor for the bug report service.
KcBugReportService get bugReportService => observability.bugReportService;
static KcObservability _defaultObservability(String environmentLabel) => KcObservability.standard(
appVersion: appVersion,
environment: environmentLabel,
platform: 'android',
source: 'kell_mobile',
);
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
factory MobileAppServices.fake() {
return MobileAppServices(
inventoryRepository: FakeInventoryRepository(),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
paymentService: FakePaymentService(),
observability: _defaultObservability('FAKE'),
);
}
/// 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),
paymentService: FakePaymentService(),
observability: _defaultObservability('WP'),
);
}
/// Creates a [MobileAppServices] backed by the real Square catalog and
/// inventory APIs.
///
/// [accessToken] – Square API access token (Bearer token).
/// [locationId] – Square location ID for inventory counts.
/// [environment] – `'sandbox'` or `'production'` (defaults to `'sandbox'`).
factory MobileAppServices.square({
required String accessToken,
required String locationId,
String environment = 'sandbox',
}) {
final apiClient = environment == 'production'
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: catalogRepo,
mappingRepository: mappingRepo,
);
return MobileAppServices(
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(),
observability: _defaultObservability('SQ'),
);
}
/// Creates a [MobileAppServices] with both WooCommerce and Square backends
/// active.
///
/// WooCommerce is used for the product catalog and orders. Square is used for
/// inventory counts. This mirrors the multi-channel commerce model where
/// WooCommerce is the catalog source of truth and Square tracks in-person
/// inventory.
factory MobileAppServices.multi({
required String wcSiteUrl,
required String wcConsumerKey,
required String wcConsumerSecret,
required String squareAccessToken,
required String squareLocationId,
String squareEnvironment = 'sandbox',
}) {
final wcApiClient = WooCommerceApiClient(
siteUrl: wcSiteUrl,
consumerKey: wcConsumerKey,
consumerSecret: wcConsumerSecret,
);
final squareApiClient = squareEnvironment == 'production'
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: wcPublishingRepo,
mappingRepository: mappingRepo,
);
return MobileAppServices(
inventoryRepository: SquareInventoryRepository(
apiClient: squareApiClient,
locationId: squareLocationId,
),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(),
observability: _defaultObservability('MULTI'),
);
}
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
///
/// Includes [createSquare] and [createMulti] callbacks so that
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
/// `KC_ENV=multi` without falling back to fake mode.
static KcServiceFactory<MobileAppServices> get serviceFactory {
return KcServiceFactory<MobileAppServices>(
createFake: () => MobileAppServices.fake(),
createWordPress: (config) => MobileAppServices.wordpress(
siteUrl: config.wcSiteUrl,
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
createSquare: (config) => MobileAppServices.square(
accessToken: config.squareAccessToken,
locationId: config.squareLocationId,
environment: config.squareEnvironment,
),
createMulti: (config) => MobileAppServices.multi(
wcSiteUrl: config.wcSiteUrl,
wcConsumerKey: config.wcConsumerKey,
wcConsumerSecret: config.wcConsumerSecret,
squareAccessToken: config.squareAccessToken,
squareLocationId: config.squareLocationId,
squareEnvironment: config.squareEnvironment,
),
);
}
}

View File

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

View File

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

View File

@ -1,153 +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 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:
case OrderStatus.onHold:
case OrderStatus.refunded:
case OrderStatus.failed:
break;
}
}
return DashboardSummary(
totalProducts: totalProducts,
inStock: inStock,
lowStock: lowStock,
outOfStock: outOfStock,
draftProducts: draftProducts,
totalOrders: totalOrders,
pendingOrders: pendingOrders,
activeOrders: activeOrders,
deliveredRevenue: deliveredRevenue,
);
}
}

View File

@ -1,38 +1,122 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'app.dart';
import 'composition/mobile_app_services.dart';
void main() {
final config = KcAppConfig.fromEnvironment();
final (:services, config: effectiveConfig) = KcBootstrap.run(
config,
MobileAppServices.serviceFactory,
);
runApp(const MyApp());
}
// Route uncaught framework and platform errors into the structured logger
// before the first frame, so startup failures are captured too.
KcErrorReporter.install(services.logger);
class MyApp extends StatelessWidget {
const MyApp({super.key});
services.logger.info(
'Application starting',
source: 'main',
context: {
'environment': effectiveConfig.environment.key,
'requestedEnvironment': config.environment.key,
'fellBackToFake': config.environment != effectiveConfig.environment,
'appVersion': MobileAppServices.appVersion,
},
);
// 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'),
);
}
}
KcErrorReporter.runGuarded(services.logger, () {
runApp(
KcAppScope<MobileAppServices>(
services: services,
config: effectiveConfig,
child: const KellMobileApp(),
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),
),
);
});
}
}

View File

@ -1,141 +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';
/// 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,
);
}
}

View File

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

View File

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

View File

@ -1,471 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// The mobile POS checkout page.
///
/// Provides a three-step flow:
/// 1. **Product selection** — browse a list of [OrderItem] catalogue entries
/// and add them to the cart.
/// 2. **Cart review** — see line items, quantities, and the running total.
/// 3. **Payment** — choose Cash or Card and confirm the charge.
///
/// After a successful payment the page shows a receipt summary and offers a
/// "New Sale" button to reset the controller and start again.
///
/// The [CheckoutController] is created externally and injected so that the
/// parent shell can hold a reference to it (e.g. to badge the Sales tab with
/// the cart item count in a future iteration).
class MobileCheckoutPage extends StatefulWidget {
final CheckoutController controller;
/// Catalogue items available for sale. In fake mode these are seeded by
/// [MobileAppServices]; in a real deployment they come from the product
/// publishing repository.
final List<OrderItem> catalogue;
const MobileCheckoutPage({super.key, required this.controller, required this.catalogue});
@override
State<MobileCheckoutPage> createState() => _MobileCheckoutPageState();
}
class _MobileCheckoutPageState extends State<MobileCheckoutPage> {
// ── lifecycle ──────────────────────────────────────────────────────────────
@override
void initState() {
super.initState();
widget.controller.addListener(_onStateChange);
}
@override
void dispose() {
widget.controller.removeListener(_onStateChange);
super.dispose();
}
void _onStateChange() => setState(() {});
// ── helpers ────────────────────────────────────────────────────────────────
CheckoutController get _ctrl => widget.controller;
String _formatCents(int cents) {
final d = cents ~/ 100;
final c = cents % 100;
return '\$$d.${c.toString().padLeft(2, '0')}';
}
// ── build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final state = _ctrl.state;
if (state is CheckoutSuccess) {
return _ReceiptView(result: state.result, onNewSale: _ctrl.reset);
}
return Column(
children: [
// ── cart summary bar ────────────────────────────────────────────────
_CartSummaryBar(
itemCount: _ctrl.cart.fold(0, (s, e) => s + e.quantity),
totalCents: _ctrl.totalCents,
formatCents: _formatCents,
onClear: _ctrl.cart.isEmpty ? null : _ctrl.clearCart,
),
const Divider(height: 1),
// ── body ────────────────────────────────────────────────────────────
Expanded(
child: state is CheckoutProcessing
? const Center(child: CircularProgressIndicator())
: _buildBody(context, state),
),
// ── error banner ────────────────────────────────────────────────────
if (state is CheckoutError) _ErrorBanner(message: state.message),
// ── pay button ──────────────────────────────────────────────────────
if (_ctrl.cart.isNotEmpty && state is! CheckoutProcessing)
_PaymentBar(
totalCents: _ctrl.totalCents,
formatCents: _formatCents,
onPay: (method) => _ctrl.processPayment(method),
),
],
);
}
Widget _buildBody(BuildContext context, CheckoutState state) {
return DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(icon: Icon(Icons.storefront_outlined), text: 'Products'),
Tab(icon: Icon(Icons.shopping_cart_outlined), text: 'Cart'),
],
),
Expanded(
child: TabBarView(
children: [
_ProductList(catalogue: widget.catalogue, onAdd: _ctrl.addItem),
_CartList(cart: _ctrl.cart, formatCents: _formatCents, onRemove: _ctrl.removeItem),
],
),
),
],
),
);
}
}
// ── Cart summary bar ──────────────────────────────────────────────────────────
class _CartSummaryBar extends StatelessWidget {
final int itemCount;
final int totalCents;
final String Function(int) formatCents;
final VoidCallback? onClear;
const _CartSummaryBar({
required this.itemCount,
required this.totalCents,
required this.formatCents,
this.onClear,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.shopping_cart, color: KcColors.denimBlue, size: 20),
const SizedBox(width: 8),
Text(
'$itemCount item${itemCount == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodyMedium,
),
const Spacer(),
Text(
formatCents(totalCents),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: KcColors.denimBlue,
),
),
if (onClear != null) ...[
const SizedBox(width: 8),
TextButton(onPressed: onClear, child: const Text('Clear')),
],
],
),
);
}
}
// ── Product list ──────────────────────────────────────────────────────────────
class _ProductList extends StatelessWidget {
final List<OrderItem> catalogue;
final void Function(OrderItem) onAdd;
const _ProductList({required this.catalogue, required this.onAdd});
@override
Widget build(BuildContext context) {
if (catalogue.isEmpty) {
return const KcEmptyState(
icon: Icons.storefront_outlined,
message: 'No products available. Add products to the catalogue to start selling.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: catalogue.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final item = catalogue[i];
return KcCard(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
title: Text(item.productName, style: Theme.of(context).textTheme.bodyMedium),
subtitle: Text('SKU: ${item.sku}', style: Theme.of(context).textTheme.bodySmall),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'\$${item.unitPrice.toStringAsFixed(2)}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: () {
HapticFeedback.lightImpact();
onAdd(item);
},
style: FilledButton.styleFrom(
minimumSize: const Size(40, 36),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Icon(Icons.add, size: 18),
),
],
),
),
);
},
);
}
}
// ── Cart list ─────────────────────────────────────────────────────────────────
class _CartList extends StatelessWidget {
final List<CartEntry> cart;
final String Function(int) formatCents;
final void Function(String sku) onRemove;
const _CartList({required this.cart, required this.formatCents, required this.onRemove});
@override
Widget build(BuildContext context) {
if (cart.isEmpty) {
return const KcEmptyState(
icon: Icons.shopping_cart_outlined,
message: 'Cart is empty. Tap + on a product to add it to the cart.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: cart.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final entry = cart[i];
return KcCard(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
title: Text(entry.item.productName, style: Theme.of(context).textTheme.bodyMedium),
subtitle: Text(
'${formatCents(entry.lineTotalCents)} · qty ${entry.quantity}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.remove_circle_outline),
tooltip: 'Remove one',
onPressed: () {
HapticFeedback.lightImpact();
onRemove(entry.item.sku);
},
),
),
);
},
);
}
}
// ── Payment bar ───────────────────────────────────────────────────────────────
class _PaymentBar extends StatelessWidget {
final int totalCents;
final String Function(int) formatCents;
final void Function(PaymentMethod) onPay;
const _PaymentBar({required this.totalCents, required this.formatCents, required this.onPay});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
Text(
'Charge ${formatCents(totalCents)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.payments_outlined),
label: const Text('Cash'),
onPressed: () => _confirmPayment(context, PaymentMethod.cash),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.credit_card),
label: const Text('Card'),
onPressed: () => _confirmPayment(context, PaymentMethod.card),
),
),
],
),
],
),
),
);
}
Future<void> _confirmPayment(BuildContext context, PaymentMethod method) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Confirm ${method.label} Payment'),
content: Text('Charge ${formatCents(totalCents)} via ${method.label}?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Confirm')),
],
),
);
if (confirmed == true) {
HapticFeedback.mediumImpact();
onPay(method);
}
}
}
// ── Receipt view ──────────────────────────────────────────────────────────────
class _ReceiptView extends StatelessWidget {
final PaymentResult result;
final VoidCallback onNewSale;
const _ReceiptView({required this.result, required this.onNewSale});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green.shade600, size: 64),
const SizedBox(height: 16),
Text(
'Payment Complete',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
result.amountFormatted,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: KcColors.denimBlue,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'via ${result.method.label}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
KcCard(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ReceiptRow(label: 'Transaction ID', value: result.transactionId),
_ReceiptRow(label: 'Completed', value: _formatTime(result.completedAt)),
if (result.receiptUrl != null)
_ReceiptRow(label: 'Receipt', value: result.receiptUrl!),
],
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
icon: const Icon(Icons.add_shopping_cart),
label: const Text('New Sale'),
onPressed: () {
HapticFeedback.lightImpact();
onNewSale();
},
),
],
),
),
);
}
String _formatTime(DateTime dt) {
final local = dt.toLocal();
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')} '
'${local.month}/${local.day}/${local.year}';
}
}
class _ReceiptRow extends StatelessWidget {
final String label;
final String value;
const _ReceiptRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
),
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodySmall)),
],
),
);
}
}
// ── Error banner ──────────────────────────────────────────────────────────────
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: Colors.red.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800),
),
),
],
),
);
}
}

View File

@ -1,263 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A full-screen mobile page for creating a new product.
///
/// Collects name (required), price (required), SKU, category, and description.
/// On submit, calls [onCreateProduct] and pops on success.
class MobileCreateProductPage extends StatefulWidget {
/// Available categories for the category dropdown.
final List<ProductCategory> availableCategories;
/// Callback invoked when the operator submits the form.
final Future<void> Function({
required String name,
required double price,
String description,
String sku,
int? categoryId,
PublishStatus status,
})
onCreateProduct;
const MobileCreateProductPage({
super.key,
required this.availableCategories,
required this.onCreateProduct,
});
@override
State<MobileCreateProductPage> createState() => _MobileCreateProductPageState();
}
class _MobileCreateProductPageState extends State<MobileCreateProductPage> {
final _nameController = TextEditingController();
final _priceController = TextEditingController();
final _skuController = TextEditingController();
final _descriptionController = TextEditingController();
int? _selectedCategoryId;
PublishStatus _selectedStatus = PublishStatus.draft;
bool _isSubmitting = false;
String? _nameError;
String? _priceError;
@override
void dispose() {
_nameController.dispose();
_priceController.dispose();
_skuController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _nameController.text.trim();
if (name.isEmpty) {
setState(() => _nameError = 'Product name is required.');
return;
}
final price = double.tryParse(_priceController.text);
if (price == null || price < 0) {
setState(() => _priceError = 'Enter a valid price (e.g. 12.99).');
return;
}
setState(() => _isSubmitting = true);
try {
await widget.onCreateProduct(
name: name,
price: price,
description: _descriptionController.text.trim(),
sku: _skuController.text.trim(),
categoryId: _selectedCategoryId,
status: _selectedStatus,
);
if (mounted) {
HapticFeedback.mediumImpact();
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to create product: $e'), backgroundColor: KcColors.danger),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('New Product'),
actions: [
if (_isSubmitting)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton(
onPressed: _submit,
child: const Text('Create', style: TextStyle(fontWeight: FontWeight.w700)),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(KcSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Name ────────────────────────────────────────────────
TextField(
controller: _nameController,
autofocus: true,
enabled: !_isSubmitting,
decoration: InputDecoration(
labelText: 'Product Name *',
hintText: 'e.g. Floral Bowl Cozy',
errorText: _nameError,
border: const OutlineInputBorder(),
),
onChanged: (_) {
if (_nameError != null) setState(() => _nameError = null);
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: KcSpacing.md),
// ── Price ────────────────────────────────────────────────
TextField(
controller: _priceController,
enabled: !_isSubmitting,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
decoration: InputDecoration(
labelText: 'Price *',
hintText: '0.00',
prefixText: '\$ ',
errorText: _priceError,
border: const OutlineInputBorder(),
),
onChanged: (_) {
if (_priceError != null) setState(() => _priceError = null);
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: KcSpacing.md),
// ── SKU ──────────────────────────────────────────────────
TextField(
controller: _skuController,
enabled: !_isSubmitting,
decoration: const InputDecoration(
labelText: 'SKU',
hintText: 'e.g. BC-FLR-001',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: KcSpacing.md),
// ── Category ─────────────────────────────────────────────
if (widget.availableCategories.isNotEmpty) ...[
DropdownButtonFormField<int?>(
initialValue: _selectedCategoryId,
decoration: const InputDecoration(
labelText: 'Category',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<int?>(value: null, child: Text('— None —')),
...widget.availableCategories.map(
(cat) => DropdownMenuItem<int?>(value: cat.id, child: Text(cat.name)),
),
],
onChanged: _isSubmitting
? null
: (value) => setState(() => _selectedCategoryId = value),
),
const SizedBox(height: KcSpacing.md),
],
// ── Description ──────────────────────────────────────────
TextField(
controller: _descriptionController,
enabled: !_isSubmitting,
maxLines: 4,
minLines: 3,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'Optional product description…',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
textInputAction: TextInputAction.newline,
),
const SizedBox(height: KcSpacing.md),
// ── Initial status ────────────────────────────────────────
Text(
'Initial Status',
style: theme.textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: KcSpacing.xs),
SegmentedButton<PublishStatus>(
segments: const [
ButtonSegment(
value: PublishStatus.draft,
label: Text('Draft'),
icon: Icon(Icons.drafts_outlined, size: 16),
),
ButtonSegment(
value: PublishStatus.pendingReview,
label: Text('Submit for Review'),
icon: Icon(Icons.rate_review_outlined, size: 16),
),
],
selected: {_selectedStatus},
onSelectionChanged: _isSubmitting
? null
: (selection) => setState(() => _selectedStatus = selection.first),
),
const SizedBox(height: KcSpacing.xl),
// ── Submit button ─────────────────────────────────────────
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.add),
label: Text(_isSubmitting ? 'Creating…' : 'Create Product'),
),
),
],
),
),
),
);
}
}

View File

@ -1,42 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
/// Full-screen mobile host for the shared [CustomOrderFormPage].
///
/// Market intake is the primary use case for custom orders, so the mobile
/// surface gets a dedicated route rather than an inline panel. The form,
/// validation, and confirmation receipt all come from `feature_orders` — no
/// business logic is forked for mobile.
///
/// The body is wrapped in a [SafeArea] so the submit button is never clipped
/// by the system gesture bar.
class MobileCustomOrderPage extends StatelessWidget {
final OrdersRepository repository;
/// Called with the created commission when the operator taps "Done".
///
/// Defaults to popping the route.
final void Function(Order order)? onComplete;
const MobileCustomOrderPage({super.key, required this.repository, this.onComplete});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Custom Order')),
body: SafeArea(
child: CustomOrderFormPage(
repository: repository,
onComplete: (order) {
final handler = onComplete;
if (handler != null) {
handler(order);
} else {
Navigator.of(context).pop(order);
}
},
),
),
);
}
}

View File

@ -1,50 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
/// Full-screen mobile host for the shared [CustomOrderTrackingPage].
///
/// Workshop tracking is a bench activity, so the mobile surface gets a
/// dedicated route rather than an inline panel. The list, stage filters,
/// advance dialog, and timeline all come from `feature_orders` — no business
/// logic is forked for mobile.
///
/// The body is wrapped in a [SafeArea] so the stage action is never clipped
/// by the system gesture bar.
class MobileCustomOrderTrackingPage extends StatelessWidget {
final OrdersRepository repository;
/// Deducts raw materials from stock when a commission completes.
///
/// Supplied by `MobileAppServices`; `null` disables deduction without
/// blocking completion.
final MaterialConsumptionPort? materialPort;
/// Optional callback that opens the custom order intake form.
final VoidCallback? onCreateCustomOrder;
/// Optional commission to pre-select on first load.
final String? initialSelectedId;
const MobileCustomOrderTrackingPage({
super.key,
required this.repository,
this.materialPort,
this.onCreateCustomOrder,
this.initialSelectedId,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Custom Order Tracking')),
body: SafeArea(
child: CustomOrderTrackingPage(
repository: repository,
materialPort: materialPort,
onCreateCustomOrder: onCreateCustomOrder,
initialSelectedId: initialSelectedId,
),
),
);
}
}

View File

@ -1,64 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
/// The Integrations page for `kell_mobile`.
///
/// When a [SquareSyncController] is provided (i.e. the app is running in
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
/// operator can trigger and monitor Square catalog + inventory syncs.
///
/// When no controller is available (fake or wordpress mode), a placeholder
/// message is shown instead.
class MobileIntegrationsPage extends StatelessWidget {
/// The Square sync controller, or `null` when Square is not configured.
final SquareSyncController? squareSyncController;
const MobileIntegrationsPage({super.key, this.squareSyncController});
@override
Widget build(BuildContext context) {
final controller = squareSyncController;
if (controller == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'No integrations configured',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Set KC_ENV=square or KC_ENV=multi with Square credentials '
'to enable the Square sync panel.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Square',
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
SquareSyncStatusPanel(controller: controller),
],
),
);
}
}

View File

@ -1,115 +0,0 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Full-screen inventory detail page for mobile.
///
/// Receives the shared [InventoryController] so that quantity adjustments are
/// immediately reflected in the list when the user pops back.
///
/// Wraps the shared [InventoryDetailPanel] from [feature_inventory] inside a
/// [Scaffold] with an [AppBar] showing the item name.
///
/// Unlike the web layout where feedback SnackBars are handled by the inventory
/// page wrapper, this detail page is pushed via [Navigator] and owns its own
/// [Scaffold]. It therefore attaches its own listener to the controller and
/// shows action-result SnackBars using its local [BuildContext], ensuring
/// they are visible on the active screen.
///
/// Quantity-adjust actions present a confirmation dialog before executing,
/// reducing accidental taps on touch screens.
class MobileInventoryDetailPage extends StatefulWidget {
final InventoryController controller;
/// Optional callback to navigate to the Products page for a given SKU.
final void Function(String sku)? onViewProduct;
const MobileInventoryDetailPage({super.key, required this.controller, this.onViewProduct});
@override
State<MobileInventoryDetailPage> createState() => _MobileInventoryDetailPageState();
}
class _MobileInventoryDetailPageState extends State<MobileInventoryDetailPage> {
InventoryController get _controller => widget.controller;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChanged);
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
super.dispose();
}
/// Handles action result feedback via SnackBars on this detail page.
///
/// Because this page is pushed on top of the list page via [Navigator],
/// the list page's listener cannot reliably display SnackBars (they
/// would appear behind this route). This listener ensures feedback is
/// always visible to the operator.
void _onControllerChanged() {
if (!mounted) return;
final result = _controller.lastActionResult;
if (result != null && !_controller.isUpdating) {
_controller.lastActionResult = null;
showInventoryActionSnackBar(context, result);
if (result.success) HapticFeedback.mediumImpact();
}
}
/// Handles the quantity adjustment flow.
///
/// Shows the shared [showAdjustQuantityDialog] and, if confirmed, calls
/// [InventoryController.adjustQuantity] with haptic feedback on success.
Future<void> _handleAdjustQuantity() async {
final item = _controller.selectedItem;
if (item == null) return;
final dialogResult = await showAdjustQuantityDialog(context, item);
if (dialogResult == null) return;
if (mounted) {
await _controller.adjustQuantity(item.id, dialogResult.delta, dialogResult.reason);
if (_controller.lastActionResult?.success == true) {
HapticFeedback.lightImpact();
}
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final item = _controller.selectedItem;
// If the item was removed or deselected, pop back.
if (item == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).pop();
});
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: Text(item.name)),
body: SafeArea(
child: InventoryDetailPanel(
item: item,
isUpdating: _controller.isUpdating,
onAdjustQuantity: _handleAdjustQuantity,
onViewProduct: widget.onViewProduct != null
? () => widget.onViewProduct!(item.sku)
: null,
),
),
);
},
);
}
}

View File

@ -1,310 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:flutter/material.dart';
import 'mobile_inventory_detail_page.dart';
/// Mobile-optimized inventory workspace page.
///
/// Provides browsing, filtering, and searching for inventory items on smaller
/// screens. Tapping an item pushes a full-screen detail page for viewing and
/// quantity adjustment.
///
/// Reuses the shared [InventoryController] and all use cases from
/// [feature_inventory] — no business logic is forked for mobile.
class MobileInventoryPage extends StatefulWidget {
final InventoryRepository repository;
/// Optional callback to navigate to the Products page for a given SKU.
final void Function(String sku)? onViewProduct;
const MobileInventoryPage({super.key, required this.repository, this.onViewProduct});
@override
State<MobileInventoryPage> createState() => _MobileInventoryPageState();
}
class _MobileInventoryPageState extends State<MobileInventoryPage> {
late final InventoryController _controller;
late final TextEditingController _searchController;
/// Whether the detail page is currently pushed on top.
///
/// When `true`, action-result SnackBars are suppressed here because the
/// detail page owns its own listener and shows feedback in its own
/// [Scaffold]. Without this guard, SnackBars would be rendered behind
/// the detail route and be invisible to the operator.
bool _detailPageActive = false;
@override
void initState() {
super.initState();
_searchController = TextEditingController();
final repo = widget.repository;
_controller = InventoryController(
GetInventoryItems(repo),
AdjustInventoryQuantity(repo),
CreateInventoryItem(repo),
UpdateInventoryItem(repo),
);
_controller.addListener(_onControllerChanged);
_controller.load();
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
_controller.dispose();
_searchController.dispose();
super.dispose();
}
/// Handles action result feedback via SnackBars.
///
/// Suppressed while the detail page is active — the detail page has its
/// own listener that shows SnackBars using its local context.
void _onControllerChanged() {
if (_detailPageActive) return;
final result = _controller.lastActionResult;
if (result != null && !_controller.isUpdating) {
_controller.lastActionResult = null;
showInventoryActionSnackBar(context, result);
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
if (_controller.isLoading) {
return const KcLoadingState(message: 'Loading inventory…');
}
if (_controller.error != null) {
return KcErrorState(message: 'Failed to load inventory.', onRetry: _controller.load);
}
return Column(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildItemCount(),
Expanded(child: _buildItemList()),
],
);
},
);
}
/// 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 by name or SKU…',
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'),
('inStock', 'In Stock'),
('lowStock', 'Low Stock'),
('outOfStock', 'Out of Stock'),
('draft', 'Draft'),
];
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),
);
},
),
);
}
/// Item count row.
Widget _buildItemCount() {
final count = _controller.items.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.xs),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'$count ${count == 1 ? 'item' : 'items'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
),
);
}
/// Scrollable inventory list using compact cards.
Widget _buildItemList() {
final items = _controller.items;
if (items.isEmpty) {
return KcEmptyState(
icon: Icons.inventory_2_outlined,
message: _controller.searchQuery.isNotEmpty || _controller.activeFilter != null
? 'No items match your criteria.'
: 'No inventory items available.',
);
}
return RefreshIndicator(
onRefresh: _controller.load,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs),
itemBuilder: (context, index) {
final item = items[index];
return SizedBox(
height: 120,
child: _MobileInventoryCard(
item: item,
isSelected: item.id == _controller.selectedItem?.id,
onTap: () => _navigateToDetail(item),
),
);
},
),
);
}
/// Navigates to the full-screen inventory detail page.
///
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
/// detail page is visible. Cleared when the detail page pops back.
void _navigateToDetail(InventoryItem item) {
_controller.selectItem(item);
setState(() => _detailPageActive = true);
Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (_) => MobileInventoryDetailPage(
controller: _controller,
onViewProduct: widget.onViewProduct,
),
),
)
.then((_) {
if (mounted) setState(() => _detailPageActive = false);
});
}
}
// ── Compact mobile inventory card ──────────────────────────────────────────────
/// A compact card for displaying an [InventoryItem] in the mobile list.
///
/// Shows name, SKU, status chip, and quantity at a glance.
class _MobileInventoryCard extends StatelessWidget {
final InventoryItem item;
final bool isSelected;
final VoidCallback onTap;
const _MobileInventoryCard({required this.item, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: isSelected ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isSelected ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none,
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(KcSpacing.sm),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'SKU: ${item.sku}',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.xs),
InventoryStatusChip(status: item.status),
],
),
),
const SizedBox(width: KcSpacing.sm),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${item.quantityOnHand}',
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
Text(
'on hand',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
],
),
const SizedBox(width: KcSpacing.xs),
const Icon(Icons.chevron_right, color: KcColors.neutral),
],
),
),
),
);
}
}

View File

@ -1,177 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Full-screen order detail page for mobile.
///
/// Receives the shared [OrdersController] so that status updates and note
/// additions are immediately reflected in the list when the user pops back.
///
/// Wraps the shared [OrderDetailPanel] from [feature_orders] inside a
/// [Scaffold] with an [AppBar] showing the order ID.
///
/// Unlike the web layout where feedback SnackBars are handled by the orders
/// page wrapper, this detail page is pushed via [Navigator] and owns its own
/// [Scaffold]. It therefore attaches its own listener to the controller and
/// shows action-result SnackBars using its local [BuildContext], ensuring
/// they are visible on the active screen.
///
/// Status-change actions present a confirmation dialog before executing,
/// reducing accidental taps on touch screens.
class MobileOrderDetailPage extends StatefulWidget {
final OrdersController controller;
/// 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 MobileOrderDetailPage({
super.key,
required this.controller,
this.onViewProduct,
this.onViewInventory,
});
@override
State<MobileOrderDetailPage> createState() => _MobileOrderDetailPageState();
}
class _MobileOrderDetailPageState extends State<MobileOrderDetailPage> {
OrdersController get _controller => widget.controller;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChanged);
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
super.dispose();
}
/// Handles action result feedback via SnackBars on this detail page.
///
/// Because this page is pushed on top of the list page via [Navigator],
/// the list page's listener cannot reliably display SnackBars (they
/// would appear behind this route). This listener ensures feedback is
/// always visible to the operator.
void _onControllerChanged() {
if (!mounted) return;
final result = _controller.lastActionResult;
if (result != null && !_controller.isUpdating) {
_controller.lastActionResult = null;
showOrderActionSnackBar(context, result);
if (result.success) HapticFeedback.mediumImpact();
}
}
/// Shows a confirmation dialog before executing a status change.
///
/// Returns `true` if the user confirmed, `false` otherwise.
Future<bool> _confirmStatusChange({
required String orderId,
required OrderStatus newStatus,
}) async {
final label = _statusLabel(newStatus);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Update to $label?'),
content: Text('Change order $orderId status to "$label"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text('Update')),
],
),
);
return confirmed ?? false;
}
/// Handles a status update with confirmation dialog.
Future<void> _handleUpdateStatus(OrderStatus newStatus) async {
final order = _controller.selectedOrder;
if (order == null) return;
final confirmed = await _confirmStatusChange(orderId: order.id, newStatus: newStatus);
if (confirmed && mounted) {
await _controller.updateOrderStatus(order.id, newStatus);
}
}
/// Handles adding a note via the shared dialog.
Future<void> _handleAddNote() async {
final order = _controller.selectedOrder;
if (order == null) return;
final dialogResult = await showAddNoteDialog(context);
if (dialogResult == null) return;
if (mounted) {
await _controller.addOrderNote(order.id, dialogResult.note);
if (_controller.lastActionResult?.success == true) {
HapticFeedback.lightImpact();
}
}
}
static String _statusLabel(OrderStatus status) {
switch (status) {
case OrderStatus.pending:
return 'Pending';
case OrderStatus.processing:
return 'Processing';
case OrderStatus.shipped:
return 'Shipped';
case OrderStatus.delivered:
return 'Delivered';
case OrderStatus.cancelled:
return 'Cancelled';
case OrderStatus.onHold:
return 'On Hold';
case OrderStatus.refunded:
return 'Refunded';
case OrderStatus.failed:
return 'Failed';
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final order = _controller.selectedOrder;
// If the order was removed or deselected, pop back.
if (order == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).pop();
});
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: Text('Order ${order.id}')),
body: SafeArea(
child: OrderDetailPanel(
order: order,
isUpdating: _controller.isUpdating,
onUpdateStatus: _handleUpdateStatus,
onAddNote: _handleAddNote,
onViewProduct: widget.onViewProduct,
onViewInventory: widget.onViewInventory,
),
),
);
},
);
}
}

View File

@ -1,314 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'mobile_custom_order_page.dart';
import 'mobile_custom_order_tracking_page.dart';
import 'mobile_order_detail_page.dart';
/// Mobile-optimized orders workspace page.
///
/// Provides browsing, filtering, and searching for orders on smaller screens.
/// Tapping an order pushes a full-screen detail page for viewing, status
/// updates, and note additions.
///
/// Reuses the shared [OrdersController] and all use cases from
/// [feature_orders] — no business logic is forked for mobile.
class MobileOrdersPage 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;
/// Deducts raw materials from stock when a commission completes.
///
/// Passed through to [MobileCustomOrderTrackingPage]; `null` disables
/// deduction without blocking completion.
final MaterialConsumptionPort? materialPort;
const MobileOrdersPage({
super.key,
required this.repository,
this.onViewProduct,
this.onViewInventory,
this.materialPort,
});
@override
State<MobileOrdersPage> createState() => _MobileOrdersPageState();
}
class _MobileOrdersPageState extends State<MobileOrdersPage> {
late final OrdersController _controller;
late final TextEditingController _searchController;
/// Whether the detail page is currently pushed on top.
///
/// When `true`, action-result SnackBars are suppressed here because the
/// detail page owns its own listener and shows feedback in its own
/// [Scaffold]. Without this guard, SnackBars would be rendered behind
/// the detail route and be invisible to the operator.
bool _detailPageActive = false;
@override
void initState() {
super.initState();
_searchController = TextEditingController();
final repo = widget.repository;
_controller = OrdersController(
GetOrders(repo),
updateOrderStatus: UpdateOrderStatus(repo),
addOrderNote: AddOrderNote(repo),
);
_controller.addListener(_onControllerChanged);
_controller.load();
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
_controller.dispose();
_searchController.dispose();
super.dispose();
}
/// Handles action result feedback via SnackBars.
///
/// Suppressed while the detail page is active — the detail page has its
/// own listener that shows SnackBars using its local context.
void _onControllerChanged() {
if (_detailPageActive) return;
final result = _controller.lastActionResult;
if (result != null && !_controller.isUpdating) {
_controller.lastActionResult = null;
showOrderActionSnackBar(context, result);
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
if (_controller.isLoading) {
return const KcLoadingState(message: 'Loading orders…');
}
if (_controller.error != null) {
return KcErrorState(message: 'Failed to load orders.', onRetry: _controller.load);
}
return Stack(
children: [
Column(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildOrderCount(),
Expanded(child: _buildOrderList()),
],
),
Positioned(
right: KcSpacing.md,
bottom: KcSpacing.md,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Tracking sits above intake: at a market the operator
// creates commissions; at the bench they track them.
FloatingActionButton.small(
heroTag: 'track-custom-orders',
tooltip: 'Track custom orders',
onPressed: _navigateToCustomOrderTracking,
child: const Icon(Icons.timeline_outlined),
),
const SizedBox(height: KcSpacing.sm),
FloatingActionButton.extended(
heroTag: 'new-custom-order',
onPressed: _navigateToCustomOrderForm,
icon: const Icon(Icons.design_services_outlined),
label: const Text('Custom Order'),
),
],
),
),
],
);
},
);
}
/// Pushes the custom order intake form and refreshes the list when a
/// commission was created.
Future<void> _navigateToCustomOrderForm() async {
setState(() => _detailPageActive = true);
await Navigator.of(context).push<Order>(
MaterialPageRoute<Order>(
builder: (_) => MobileCustomOrderPage(repository: widget.repository),
),
);
if (!mounted) return;
setState(() => _detailPageActive = false);
await _controller.load();
}
/// Pushes the custom order tracking workspace and refreshes the list on
/// return, because advancing a commission can change its order status.
Future<void> _navigateToCustomOrderTracking() async {
setState(() => _detailPageActive = true);
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => MobileCustomOrderTrackingPage(
repository: widget.repository,
materialPort: widget.materialPort,
onCreateCustomOrder: _navigateToCustomOrderForm,
),
),
);
if (!mounted) return;
setState(() => _detailPageActive = false);
await _controller.load();
}
/// 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 by customer or order ID…',
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'),
('pending', 'Pending'),
('processing', 'Processing'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
('cancelled', 'Cancelled'),
];
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),
);
},
),
);
}
/// Order count row.
Widget _buildOrderCount() {
final count = _controller.orders.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.xs),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'$count ${count == 1 ? 'order' : 'orders'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
),
);
}
/// Scrollable order list using compact cards.
Widget _buildOrderList() {
final orders = _controller.orders;
if (orders.isEmpty) {
return KcEmptyState(
icon: Icons.receipt_long_outlined,
message: _controller.searchQuery.isNotEmpty || _controller.activeFilter != null
? 'No orders match your criteria.'
: 'No orders available.',
);
}
return RefreshIndicator(
onRefresh: _controller.load,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
itemCount: orders.length,
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs),
itemBuilder: (context, index) {
final order = orders[index];
return SizedBox(
height: 160,
child: OrderCard(
order: order,
isSelected: order.id == _controller.selectedOrder?.id,
onTap: () => _navigateToDetail(order),
),
);
},
),
);
}
/// Navigates to the full-screen order detail page.
///
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
/// detail page is visible. Cleared when the detail page pops back.
void _navigateToDetail(Order order) {
_controller.selectOrder(order);
setState(() => _detailPageActive = true);
Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (_) => MobileOrderDetailPage(
controller: _controller,
onViewProduct: widget.onViewProduct,
onViewInventory: widget.onViewInventory,
),
),
)
.then((_) {
if (mounted) setState(() => _detailPageActive = false);
});
}
}

View File

@ -1,177 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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.
///
/// Unlike the web layout where feedback SnackBars are handled by the
/// publishing page wrapper, this detail page is pushed via [Navigator]
/// and owns its own [Scaffold]. It therefore attaches its own listener
/// to the controller and shows action-result SnackBars using its local
/// [BuildContext], ensuring they are visible on the active screen.
///
/// Status-change actions (publish / move-to-draft) present a
/// confirmation dialog before executing, reducing accidental taps on
/// touch screens.
class MobileProductDetailPage extends StatefulWidget {
final ProductPublishingController controller;
const MobileProductDetailPage({super.key, required this.controller});
@override
State<MobileProductDetailPage> createState() => _MobileProductDetailPageState();
}
class _MobileProductDetailPageState extends State<MobileProductDetailPage> {
ProductPublishingController get _controller => widget.controller;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChanged);
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
super.dispose();
}
/// Handles action result feedback via SnackBars on this detail page.
///
/// Because this page is pushed on top of the list page via [Navigator],
/// the list page's listener cannot reliably display SnackBars (they
/// would appear behind this route). This listener ensures feedback is
/// always visible to the operator.
void _onControllerChanged() {
if (!mounted) return;
final result = _controller.lastActionResult;
if (result != null) {
_controller.consumeActionResult();
showStatusActionSnackBar(context, result);
if (result.success) HapticFeedback.mediumImpact();
}
final priceResult = _controller.lastPriceResult;
if (priceResult != null) {
_controller.consumePriceResult();
showPriceActionSnackBar(context, priceResult);
if (priceResult.success) HapticFeedback.lightImpact();
}
final nameResult = _controller.lastNameResult;
if (nameResult != null) {
_controller.consumeNameResult();
showNameActionSnackBar(context, nameResult);
if (nameResult.success) HapticFeedback.lightImpact();
}
final descriptionResult = _controller.lastDescriptionResult;
if (descriptionResult != null) {
_controller.consumeDescriptionResult();
showDescriptionActionSnackBar(context, descriptionResult);
if (descriptionResult.success) HapticFeedback.lightImpact();
}
final categoryResult = _controller.lastCategoryResult;
if (categoryResult != null) {
_controller.consumeCategoryResult();
showCategoryActionSnackBar(context, categoryResult);
if (categoryResult.success) HapticFeedback.lightImpact();
}
}
/// Shows a confirmation dialog before executing a status change.
///
/// Returns `true` if the user confirmed, `false` otherwise.
Future<bool> _confirmStatusChange({
required String productName,
required String actionLabel,
required String description,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('$actionLabel?'),
content: Text(description),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(actionLabel)),
],
),
);
return confirmed ?? false;
}
/// Publishes the product after confirmation.
Future<void> _handlePublish(String id, String name) async {
final confirmed = await _confirmStatusChange(
productName: name,
actionLabel: 'Publish',
description: 'Publish "$name" to the store? This will make it visible to customers.',
);
if (confirmed && mounted) {
_controller.updateStatus(id, PublishStatus.published);
}
}
/// Moves the product to draft after confirmation.
Future<void> _handleMoveToDraft(String id, String name) async {
final confirmed = await _confirmStatusChange(
productName: name,
actionLabel: 'Move to Draft',
description: 'Move "$name" back to draft? This will remove it from the store.',
);
if (confirmed && mounted) {
_controller.updateStatus(id, PublishStatus.draft);
}
}
@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: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: ProductPreviewPanel(
draft: draft,
isUpdating: _controller.isUpdating(draft.id),
onPublish: () => _handlePublish(draft.id, draft.name),
onMoveToDraft: () => _handleMoveToDraft(draft.id, draft.name),
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),
),
),
),
);
},
);
}
}

View File

@ -1,342 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'mobile_create_product_page.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;
/// Whether the detail page is currently pushed on top.
///
/// When `true`, action-result SnackBars are suppressed here because the
/// detail page owns its own listener and shows feedback in its own
/// [Scaffold]. Without this guard, SnackBars would be rendered behind
/// the detail route and be invisible to the operator.
bool _detailPageActive = false;
@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),
createProduct: CreateProduct(repo),
getCategories: GetCategories(repo),
);
_controller.addListener(_onControllerChanged);
_controller.load();
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
_controller.dispose();
_searchController.dispose();
super.dispose();
}
/// Handles action result feedback via SnackBars.
///
/// Suppressed while the detail page is active — the detail page has its
/// own listener that shows SnackBars using its local context.
void _onControllerChanged() {
if (_detailPageActive) return;
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 Stack(
children: [
Column(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildSortAndCount(),
Expanded(child: _buildProductList()),
],
),
Positioned(
right: KcSpacing.md,
bottom: KcSpacing.md,
child: FloatingActionButton.extended(
heroTag: 'mobile_create_product_fab',
onPressed: _openCreateProductPage,
icon: const Icon(Icons.add),
label: const Text('New Product'),
),
),
],
);
},
);
}
/// 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),
),
);
},
),
);
}
/// Opens the [MobileCreateProductPage] to create a new product.
Future<void> _openCreateProductPage() async {
final categories = await _controller.fetchCategories();
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => MobileCreateProductPage(
availableCategories: categories,
onCreateProduct:
({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) => _controller.createNewProduct(
name: name,
price: price,
description: description,
sku: sku,
categoryId: categoryId,
status: status,
),
),
),
);
// Reload after returning so the new product appears in the list.
if (mounted) await _controller.load();
}
/// Navigates to the full-screen product detail page.
///
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
/// detail page is visible. Cleared when the detail page pops back.
void _navigateToDetail(ProductDraft draft) {
_controller.selectDraft(draft);
setState(() => _detailPageActive = true);
Navigator.of(context)
.push(
MaterialPageRoute<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
)
.then((_) {
if (mounted) setState(() => _detailPageActive = false);
});
}
}

View File

@ -1,306 +0,0 @@
import 'package:core/core.dart';
import 'package:design_system/design_system.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/mobile_checkout_page.dart';
import '../pages/mobile_integrations_page.dart';
import '../pages/mobile_inventory_page.dart';
import '../pages/mobile_orders_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;
/// Dashboard controller is created once and reused across rebuilds to avoid
/// re-triggering data loads on every [setState] call (e.g. tab switches).
late DashboardController _dashboardController;
late CheckoutController _checkoutController;
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'Sales', 'More'];
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Initialise the controller here (not in initState) because it needs
// KcAppScope which requires an inherited widget lookup.
if (!_dashboardControllerInitialised) {
final services = KcAppScope.of<MobileAppServices>(context);
_dashboardController = DashboardController(
GetDashboardSummary(
inventoryRepository: services.inventoryRepository,
productPublishingRepository: services.productPublishingRepository,
ordersRepository: services.ordersRepository,
),
);
_checkoutController = CheckoutController(paymentService: services.paymentService);
_dashboardControllerInitialised = true;
}
}
bool _dashboardControllerInitialised = false;
@override
void dispose() {
_dashboardController.dispose();
_checkoutController.dispose();
super.dispose();
}
/// Opens the shared bug report dialog and submits the resulting draft with
/// the current screen and recent log tail attached as evidence.
Future<void> _reportProblem() async {
final services = KcAppScope.of<MobileAppServices>(context);
final screen = _titles[_selectedIndex];
final draft = await KcBugReportDialog.show(context, screen: screen);
if (draft == null || !mounted) return;
final result = await services.bugReportService.submit(
title: draft.title,
description: draft.description,
severity: KcBugSeverity.fromString(draft.severityName),
reporter: draft.reporter,
screen: screen,
diagnostics: {'tabIndex': _selectedIndex},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.succeeded
? 'Report ${result.report!.id} submitted. Thank you.'
: result.errorMessage ?? 'Could not submit the report.',
),
),
);
}
@override
Widget build(BuildContext context) {
final config = KcAppScope.configOf<MobileAppServices>(context);
return Scaffold(
appBar: AppBar(
title: Text(_titles[_selectedIndex]),
actions: [
IconButton(
icon: const Icon(Icons.bug_report_outlined),
tooltip: 'Report a problem',
onPressed: _reportProblem,
),
_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.point_of_sale_outlined),
selectedIcon: Icon(Icons.point_of_sale),
label: 'Sales',
),
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);
case 1:
return MobileInventoryPage(
repository: services.inventoryRepository,
onViewProduct: (_) {
// Cross-feature nav: switch to Products tab.
setState(() => _selectedIndex = 2);
},
);
case 2:
return MobilePublishingPage(repository: services.productPublishingRepository);
case 3:
return MobileOrdersPage(
repository: services.ordersRepository,
materialPort: services.materialConsumptionPort,
onViewProduct: (_) {
setState(() => _selectedIndex = 2);
},
onViewInventory: (_) {
setState(() => _selectedIndex = 1);
},
);
case 4:
return MobileCheckoutPage(
controller: _checkoutController,
catalogue: services.checkoutCatalogue,
);
case 5:
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: () {
final services = KcAppScope.of<MobileAppServices>(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Integrations')),
body: MobileIntegrationsPage(squareSyncController: services.squareSyncController),
),
),
);
},
),
],
);
}
}
/// 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;
case KcAppEnvironment.square:
backgroundColor = Colors.blue.shade100;
foregroundColor = Colors.blue.shade900;
case KcAppEnvironment.multi:
backgroundColor = Colors.purple.shade100;
foregroundColor = Colors.purple.shade900;
}
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(4)),
child: Text(
environment.label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
);
}
}

View File

@ -29,10 +29,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.3"
version: "1.1.2"
collection:
dependency: transitive
description:
@ -41,13 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
core:
dependency: "direct main"
description:
path: "../../packages/core"
relative: true
source: path
version: "0.0.1"
cupertino_icons:
dependency: "direct main"
description:
@ -56,13 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
design_system:
dependency: "direct main"
description:
path: "../../packages/design_system"
relative: true
source: path
version: "0.0.1"
fake_async:
dependency: transitive
description:
@ -71,34 +57,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description: flutter
@ -117,29 +75,6 @@ packages:
description: flutter
source: sdk
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"
integrations:
dependency: "direct main"
description:
path: "../../packages/integrations"
relative: true
source: path
version: "0.0.1"
leak_tracker:
dependency: transitive
description:
@ -176,10 +111,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.20"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@ -192,10 +127,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.3"
version: "1.17.0"
path:
dependency: transitive
description:
@ -221,10 +156,10 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
@ -253,42 +188,26 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "0.7.10"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "15.0.2"
sdks:
dart: ">=3.11.0 <4.0.0"
dart: ">=3.11.4 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@ -1,38 +1,89 @@
name: kell_mobile
description: "Kell Creations mobile operations platform."
publish_to: "none"
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# 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
environment:
sdk: ^3.11.0
sdk: ^3.11.4
# 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:
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
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
integrations:
path: ../../packages/integrations
feature_wordpress:
path: ../../packages/feature_wordpress
dev_dependencies:
flutter_test:
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
# 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:
# 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
# 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

View File

@ -1,151 +0,0 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/composition/inventory_material_consumption_adapter.dart';
/// An inventory repository whose every operation throws.
class _ThrowingInventory extends FakeInventoryRepository {
@override
Future<InventoryItem?> getItemBySku(String sku) async => throw Exception('inventory offline');
}
/// An inventory repository that finds items but rejects every adjustment.
class _RejectingInventory extends FakeInventoryRepository {
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) async =>
const InventoryAdjustmentResult.failure('Insufficient stock.');
}
InventoryMaterialConsumptionAdapter _adapter([InventoryRepository? inventory]) =>
InventoryMaterialConsumptionAdapter(inventory: inventory ?? FakeInventoryRepository());
void main() {
// 'BC-FLR-001' is a seeded SKU in FakeInventoryRepository.
const known = MaterialUsage(sku: 'BC-FLR-001', quantity: 2, displayName: 'Floral Bowl Cozy');
const alsoKnown = MaterialUsage(sku: 'CS-CIT-002', quantity: 1);
const unknown = MaterialUsage(sku: 'SUP-DOES-NOT-EXIST', quantity: 3);
group('InventoryMaterialConsumptionAdapter — success', () {
test('deducts a known SKU', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [known]);
expect(result.success, isTrue);
expect(result.deducted, [known]);
});
test('actually decrements the stock level', () async {
final inventory = FakeInventoryRepository();
final before = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
await _adapter(inventory).deductMaterials('KC-C2001', const [known]);
final after = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
expect(after, before - 2);
});
test('deducts every supplied material', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [known, alsoKnown]);
expect(result.deducted, hasLength(2));
expect(result.totalUnitsDeducted, 3);
});
test('an empty request succeeds with nothing deducted', () async {
final result = await _adapter().deductMaterials('KC-C2001', const []);
expect(result.success, isTrue);
expect(result.deducted, isEmpty);
});
});
group('InventoryMaterialConsumptionAdapter — failure tolerance', () {
test('records a missing SKU as a failure', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [unknown]);
expect(result.success, isFalse);
expect(result.failures['SUP-DOES-NOT-EXIST'], contains('No inventory item'));
});
test('one missing SKU does not abort the others', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [unknown, known]);
expect(result.deducted, [known]);
expect(result.failures.keys, ['SUP-DOES-NOT-EXIST']);
expect(result.isPartial, isTrue);
});
test('records an invalid usage record as a failure without calling inventory', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [
MaterialUsage(sku: 'SUP-BAD', quantity: 0),
]);
expect(result.failures['SUP-BAD'], contains('Invalid material record'));
});
test('records a rejected adjustment as a failure', () async {
final result = await _adapter(
_RejectingInventory(),
).deductMaterials('KC-C2001', const [known]);
expect(result.success, isFalse);
expect(result.failures['BC-FLR-001'], contains('Insufficient stock'));
});
test('never throws when the repository throws', () async {
final result = await _adapter(
_ThrowingInventory(),
).deductMaterials('KC-C2001', const [known]);
expect(result.success, isFalse);
expect(result.failures['BC-FLR-001'], contains('Inventory error'));
});
test('a throwing repository still processes every SKU', () async {
final result = await _adapter(
_ThrowingInventory(),
).deductMaterials('KC-C2001', const [known, alsoKnown]);
expect(result.failures, hasLength(2));
});
test('the failure summary names the SKUs but no customer or money detail', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [unknown, known]);
expect(result.summary, contains('SUP-DOES-NOT-EXIST'));
expect(result.summary, isNot(contains(r'$')));
});
});
group('CompleteCustomOrder — end-to-end with the real adapter', () {
test('completing a commission decrements real inventory', () async {
final inventory = FakeInventoryRepository();
final orders = FakeOrdersRepository(now: () => DateTime(2026, 5, 1));
await orders.recordMaterialUsage('KC-C2005', const [known]);
final before = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
final result = await CompleteCustomOrder(orders, materialPort: _adapter(inventory))(
'KC-C2005',
);
expect(result.success, isTrue);
expect((await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand, before - 2);
});
test('a rejected completion leaves inventory untouched', () async {
final inventory = FakeInventoryRepository();
final orders = FakeOrdersRepository(now: () => DateTime(2026, 5, 1));
await orders.recordMaterialUsage('KC-C2001', const [known]);
final before = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
// KC-C2001 is only quoted; it cannot jump straight to completed.
final result = await CompleteCustomOrder(orders, materialPort: _adapter(inventory))(
'KC-C2001',
);
expect(result.success, isFalse);
expect((await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand, before);
});
});
}

View File

@ -1,316 +0,0 @@
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';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/composition/mobile_app_services.dart';
void main() {
group('MobileAppServices', () {
// ── fake factory ──────────────────────────────────────────────────────────
group('fake()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.fake();
expect(services, isA<MobileAppServices>());
expect(services, isA<KcAppServices>());
});
test('inventoryRepository is FakeInventoryRepository', () {
final services = MobileAppServices.fake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
test('productPublishingRepository is FakeProductPublishingRepository', () {
final services = MobileAppServices.fake();
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
});
});
// ── wordpress factory ─────────────────────────────────────────────────────
group('wordpress()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services, isA<MobileAppServices>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// ── square factory ────────────────────────────────────────────────────────
group('square()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<MobileAppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is SquareCatalogRepository', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('squareSyncController is non-null', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.squareSyncController, isNotNull);
expect(services.squareSyncController, isA<SquareSyncController>());
services.squareSyncController!.dispose();
});
test('defaults to sandbox environment', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<MobileAppServices>());
});
test('accepts production environment', () {
final services = MobileAppServices.square(
accessToken: 'EAAAl_test',
locationId: 'LOC123',
environment: 'production',
);
expect(services, isA<MobileAppServices>());
});
});
// ── multi factory ─────────────────────────────────────────────────────────
group('multi()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services, isA<MobileAppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
});
// ── serviceFactory ────────────────────────────────────────────────────────
group('serviceFactory', () {
test('createFake returns fake services', () {
final factory = MobileAppServices.serviceFactory;
final services = factory.createFake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
});
test('createWordPress returns wordpress services', () {
final factory = MobileAppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.wordpress,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
);
final services = factory.createWordPress(config);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('createSquare is non-null', () {
final factory = MobileAppServices.serviceFactory;
expect(factory.createSquare, isNotNull);
});
test('createSquare returns square services', () {
final factory = MobileAppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createSquare!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('createMulti is non-null', () {
final factory = MobileAppServices.serviceFactory;
expect(factory.createMulti, isNotNull);
});
test('createMulti returns multi services', () {
final factory = MobileAppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createMulti!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.square);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.multi);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
expect(
result.services.productPublishingRepository,
isA<WordPressProductPublishingRepository>(),
);
});
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: '',
squareLocationId: '',
);
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.fake);
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// ── paymentService composition (Stage 10C) ────────────────────────────────
group('paymentService', () {
test('fake() provides a FakePaymentService', () {
final services = MobileAppServices.fake();
expect(services.paymentService, isA<FakePaymentService>());
});
test('wordpress() provides a FakePaymentService', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.paymentService, isA<FakePaymentService>());
});
test('square() provides a FakePaymentService', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.paymentService, isA<FakePaymentService>());
});
test('multi() provides a FakePaymentService', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.paymentService, isA<FakePaymentService>());
});
});
// ── checkoutCatalogue composition (Stage 10C) ─────────────────────────────
group('checkoutCatalogue', () {
test('fake() provides a non-empty default catalogue', () {
final services = MobileAppServices.fake();
expect(services.checkoutCatalogue, isNotEmpty);
});
test('default catalogue items have valid SKUs and prices', () {
final services = MobileAppServices.fake();
for (final item in services.checkoutCatalogue) {
expect(item.sku, isNotEmpty);
expect(item.unitPrice, greaterThan(0));
expect(item.productName, isNotEmpty);
}
});
test('custom catalogue can be injected', () {
const custom = [
OrderItem(productName: 'Custom Item', sku: 'CI-001', quantity: 1, unitPrice: 99.99),
];
final services = MobileAppServices(
inventoryRepository: FakeInventoryRepository(),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
paymentService: FakePaymentService(),
checkoutCatalogue: custom,
);
expect(services.checkoutCatalogue.length, 1);
expect(services.checkoutCatalogue.first.sku, 'CI-001');
});
});
});
}

View File

@ -1,156 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/pages/mobile_checkout_page.dart';
const _catalogue = [
OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00),
OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50),
];
Widget _buildPage({CheckoutController? controller, List<OrderItem> catalogue = _catalogue}) {
final ctrl = controller ?? CheckoutController(paymentService: FakePaymentService());
return MaterialApp(
home: Scaffold(
body: MobileCheckoutPage(controller: ctrl, catalogue: catalogue),
),
);
}
void main() {
group('MobileCheckoutPage', () {
late CheckoutController controller;
setUp(() {
controller = CheckoutController(paymentService: FakePaymentService());
});
tearDown(() => controller.dispose());
// ── initial render ────────────────────────────────────────────────────────
testWidgets('renders Products and Cart tabs', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('Products'), findsOneWidget);
expect(find.text('Cart'), findsOneWidget);
});
testWidgets('shows cart summary bar with 0 items initially', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('0 items'), findsOneWidget);
});
testWidgets('shows product names from catalogue', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('Widget A'), findsOneWidget);
expect(find.text('Widget B'), findsOneWidget);
});
testWidgets('shows empty state when catalogue is empty', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller, catalogue: const []));
expect(
find.text('No products available. Add products to the catalogue to start selling.'),
findsOneWidget,
);
});
// ── add to cart ───────────────────────────────────────────────────────────
testWidgets('tapping + adds item to cart and updates summary', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
// Tap the + button for Widget A (first add icon)
final addButtons = find.byIcon(Icons.add);
expect(addButtons, findsWidgets);
await tester.tap(addButtons.first);
await tester.pump();
expect(find.text('1 item'), findsOneWidget);
});
testWidgets('adding two different items shows 2 items in summary', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
final addButtons = find.byIcon(Icons.add);
await tester.tap(addButtons.first);
await tester.pump();
await tester.tap(addButtons.last);
await tester.pump();
expect(find.text('2 items'), findsOneWidget);
});
// ── payment bar ───────────────────────────────────────────────────────────
testWidgets('payment bar appears after adding item', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
await tester.tap(find.byIcon(Icons.add).first);
await tester.pump();
expect(find.text('Cash'), findsOneWidget);
expect(find.text('Card'), findsOneWidget);
});
testWidgets('payment bar is hidden when cart is empty', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('Cash'), findsNothing);
expect(find.text('Card'), findsNothing);
});
// ── cart tab ──────────────────────────────────────────────────────────────
testWidgets('switching to Cart tab shows empty state when cart is empty', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
// Tap the Cart tab by its icon to avoid ambiguity with the 'Cart' label
// in the summary bar (which also contains the word 'Cart').
await tester.tap(find.byIcon(Icons.shopping_cart_outlined));
await tester.pumpAndSettle();
// The KcEmptyState message for an empty cart.
expect(find.text('Cart is empty. Tap + on a product to add it to the cart.'), findsOneWidget);
});
// ── receipt view ──────────────────────────────────────────────────────────
testWidgets('shows receipt view after successful payment', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
// Add item
await tester.tap(find.byIcon(Icons.add).first);
await tester.pump();
// Tap Cash button
await tester.tap(find.text('Cash'));
await tester.pumpAndSettle();
// Confirm dialog
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Payment Complete'), findsOneWidget);
expect(find.text('New Sale'), findsOneWidget);
});
testWidgets('New Sale button resets to idle state', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
await tester.tap(find.byIcon(Icons.add).first);
await tester.pump();
await tester.tap(find.text('Cash'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Payment Complete'), findsOneWidget);
await tester.tap(find.text('New Sale'));
await tester.pump();
// Back to product list
expect(find.text('Products'), findsOneWidget);
expect(find.text('0 items'), findsOneWidget);
});
});
}

View File

@ -1,61 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/pages/mobile_custom_order_page.dart';
Widget _buildPage({void Function(Order order)? onComplete}) {
return MaterialApp(
home: MobileCustomOrderPage(repository: FakeOrdersRepository(), onComplete: onComplete),
);
}
void main() {
group('MobileCustomOrderPage', () {
testWidgets('shows the New Custom Order app bar title', (tester) async {
await tester.pumpWidget(_buildPage());
expect(find.byType(AppBar), findsOneWidget);
expect(find.text('New Custom Order'), findsOneWidget);
});
testWidgets('hosts the shared CustomOrderFormPage', (tester) async {
await tester.pumpWidget(_buildPage());
expect(find.byType(CustomOrderFormPage), findsOneWidget);
});
testWidgets('wraps the form in a SafeArea', (tester) async {
await tester.pumpWidget(_buildPage());
expect(find.byType(SafeArea), findsWidgets);
});
testWidgets('renders the customer and pricing sections', (tester) async {
await tester.pumpWidget(_buildPage());
expect(find.text('Customer'), findsOneWidget);
expect(find.text('Pricing'), findsOneWidget);
});
testWidgets('renders the submit action', (tester) async {
await tester.pumpWidget(_buildPage());
expect(find.text('Create Custom Order'), findsOneWidget);
});
testWidgets('submitting an empty form shows validation errors', (tester) async {
// The intake form is long; the default 800×600 test view puts the submit
// button outside the hit-test area. On a device the page scrolls.
tester.view.physicalSize = const Size(1200, 4000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(_buildPage());
await tester.tap(find.text('Create Custom Order'));
await tester.pump();
expect(find.text('Customer name is required.'), findsOneWidget);
});
});
}

View File

@ -1,100 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/composition/inventory_material_consumption_adapter.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:kell_mobile/pages/mobile_custom_order_tracking_page.dart';
DateTime _fixedNow() => DateTime(2026, 5, 1, 12);
Widget _buildPage({MaterialConsumptionPort? port, VoidCallback? onCreateCustomOrder}) {
return MaterialApp(
home: MobileCustomOrderTrackingPage(
repository: FakeOrdersRepository(now: _fixedNow),
materialPort: port,
onCreateCustomOrder: onCreateCustomOrder,
),
);
}
/// Mounts the page tall enough that the master list and detail both render.
Future<void> _pump(WidgetTester tester, {Widget? page}) async {
tester.view.physicalSize = const Size(900, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(page ?? _buildPage());
await tester.pumpAndSettle();
}
void main() {
group('MobileCustomOrderTrackingPage', () {
testWidgets('shows the tracking app bar title', (tester) async {
await _pump(tester);
expect(find.byType(AppBar), findsOneWidget);
expect(find.text('Custom Order Tracking'), findsOneWidget);
});
testWidgets('hosts the shared CustomOrderTrackingPage', (tester) async {
await _pump(tester);
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
});
testWidgets('wraps the body in a SafeArea', (tester) async {
await _pump(tester);
expect(find.byType(SafeArea), findsWidgets);
});
testWidgets('lists the seeded commissions', (tester) async {
await _pump(tester);
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('renders the stage filter chips with counts', (tester) async {
await _pump(tester);
expect(find.text('All (5)'), findsOneWidget);
expect(find.text('Quoted (1)'), findsOneWidget);
});
testWidgets('filtering by stage narrows the list', (tester) async {
await _pump(tester);
// The chip row scrolls horizontally; at mobile width the later stages
// sit under the count label until scrolled into view.
await tester.scrollUntilVisible(
find.text('In Production (1)'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('In Production (1)'));
await tester.pumpAndSettle();
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
testWidgets('omits the intake action when no callback is supplied', (tester) async {
await _pump(tester);
expect(find.text('New Custom Order'), findsNothing);
});
testWidgets('renders the intake action when a callback is supplied', (tester) async {
await _pump(tester, page: _buildPage(onCreateCustomOrder: () {}));
expect(find.text('New Custom Order'), findsOneWidget);
});
testWidgets('accepts a material consumption port', (tester) async {
final port = InventoryMaterialConsumptionAdapter(inventory: FakeInventoryRepository());
await _pump(tester, page: _buildPage(port: port));
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
});
});
}

View File

@ -1,53 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/pages/mobile_integrations_page.dart';
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
SquareSyncController _fakeController() {
final syncService = SquareCatalogSyncService(
catalogRepository: FakeProductPublishingRepository(),
mappingRepository: FakeProductIdMappingRepository(),
);
return SquareSyncController(syncService: syncService);
}
void main() {
group('MobileIntegrationsPage', () {
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
await tester.pump();
expect(find.text('No integrations configured'), findsOneWidget);
});
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square Sync'), findsOneWidget);
expect(find.text('Sync Now'), findsOneWidget);
});
testWidgets('shows Square section label when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square'), findsOneWidget);
});
testWidgets('placeholder shows hub icon', (tester) async {
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
await tester.pump();
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
});
});
}

View File

@ -1,153 +0,0 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/app.dart';
import 'package:kell_mobile/composition/mobile_app_services.dart';
/// Pumps the mobile app with fake services so the shell's bug report action
/// can be exercised end-to-end.
Future<MobileAppServices> _pumpApp(WidgetTester tester) async {
final services = MobileAppServices.fake();
await tester.pumpWidget(
KcAppScope<MobileAppServices>(
services: services,
config: const KcAppConfig(
environment: KcAppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
),
child: const KellMobileApp(),
),
);
await tester.pumpAndSettle();
return services;
}
/// Matches the Nth [TextField] inside the open bug report dialog.
///
/// Scoped to the dialog because feature pages behind it (for example the
/// inventory search bar) contribute their own text fields.
Finder _dialogField(int index) => find.descendant(
of: find.byType(AlertDialog),
matching: find.byType(TextField),
).at(index);
void main() {
group('MobileAppServices observability wiring', () {
test('fake factory builds a usable observability stack', () {
final services = MobileAppServices.fake();
expect(services.observability, isNotNull);
expect(services.logger, same(services.observability.logger));
expect(services.bugReportService, same(services.observability.bugReportService));
});
test('logger and bug report service share the same evidence buffer', () async {
final services = MobileAppServices.fake();
services.logger.error('Checkout failed', context: {'sku': 'MWH-001'});
final result = await services.bugReportService.submit(
title: 'Checkout broken',
description: 'Card payment does nothing.',
);
expect(result.succeeded, isTrue);
expect(result.report!.hasFailureEvidence, isTrue);
expect(
result.report!.logTail.any((r) => r.message == 'Checkout failed'),
isTrue,
reason: 'the log tail must come from the same buffer the logger writes to',
);
});
test('stamps the android platform and app version', () async {
final services = MobileAppServices.fake();
final result = await services.bugReportService.submit(title: 'T', description: 'D');
expect(result.report!.platform, 'android');
expect(result.report!.appVersion, MobileAppServices.appVersion);
});
test('square factory labels reports with the SQ environment', () async {
final services = MobileAppServices.square(accessToken: 'tok', locationId: 'loc');
final result = await services.bugReportService.submit(title: 'T', description: 'D');
expect(result.report!.environment, 'SQ');
});
});
group('MobileShell report a problem action', () {
testWidgets('exposes a bug report action in the app bar', (tester) async {
await _pumpApp(tester);
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
expect(find.byTooltip('Report a problem'), findsOneWidget);
});
testWidgets('opens the shared bug report dialog', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
expect(find.text('Report a problem'), findsOneWidget);
expect(find.text('Screen: Dashboard'), findsOneWidget);
});
testWidgets('submits the report and confirms via snack bar', (tester) async {
final services = await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
await tester.enterText(_dialogField(0), 'Dashboard blank');
await tester.enterText(_dialogField(1), 'No KPI cards render.');
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(find.textContaining('submitted. Thank you.'), findsOneWidget);
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
expect(sink.reports, hasLength(1));
expect(sink.reports.single.title, 'Dashboard blank');
expect(sink.reports.single.screen, 'Dashboard');
expect(sink.reports.single.diagnostics['tabIndex'], 0);
});
testWidgets('cancelling submits nothing', (tester) async {
final services = await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
expect(sink.reports, isEmpty);
expect(find.textContaining('submitted'), findsNothing);
});
testWidgets('records the active tab as the reporting screen', (tester) async {
final services = await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.inventory_2_outlined));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
expect(find.text('Screen: Inventory'), findsOneWidget);
await tester.enterText(_dialogField(0), 'Stock wrong');
await tester.enterText(_dialogField(1), 'Counts do not match.');
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
expect(sink.reports.single.screen, 'Inventory');
expect(sink.reports.single.diagnostics['tabIndex'], 1);
});
});
}

View File

@ -1,543 +1,30 @@
import 'package:core/core.dart';
// 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_test/flutter_test.dart';
import 'package:kell_mobile/app.dart';
import 'package:kell_mobile/composition/mobile_app_services.dart';
Widget _buildTestApp() {
const config = KcAppConfig(
environment: KcAppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
);
return KcAppScope<MobileAppServices>(
services: MobileAppServices.fake(),
config: config,
child: const KellMobileApp(),
);
}
import 'package:kell_mobile/main.dart';
void main() {
testWidgets('mobile shell loads with dashboard tab', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
expect(find.text('Dashboard'), findsWidgets);
});
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
testWidgets('dashboard shows summary cards after loading', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('Overview'), findsOneWidget);
expect(find.text('Total Products'), 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 6 destinations', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
// Dashboard, Inventory, Products, Orders, Sales, More
expect(find.byType(NavigationDestination), findsNWidgets(6));
});
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);
});
// ── Stage 6A: Android feedback and action polish tests ────────────
testWidgets('tapping product card navigates to detail page', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first product card in the list
final cards = find.byType(GestureDetector);
expect(cards, findsWidgets);
// Tap on the first product in the list view
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Should now be on the detail page — has an AppBar with a back button
expect(find.byType(AppBar), findsOneWidget);
}
});
testWidgets('detail page shows confirmation dialog for publish action', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first 72-height card to navigate to detail
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Scroll down to reveal the status action button
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -300));
await tester.pumpAndSettle();
// Look for Publish or Move to Draft button
final publishButton = find.text('Publish to Store');
final moveToDraftButton = find.text('Move to Draft');
if (publishButton.evaluate().isNotEmpty) {
await tester.tap(publishButton);
await tester.pumpAndSettle();
// Confirmation dialog should appear
expect(find.text('Publish?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('Publish'), findsOneWidget);
// Cancel should dismiss the dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
// Dialog should be gone
expect(find.text('Publish?'), findsNothing);
} else if (moveToDraftButton.evaluate().isNotEmpty) {
await tester.tap(moveToDraftButton);
await tester.pumpAndSettle();
// Confirmation dialog should appear
expect(find.text('Move to Draft?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
// Cancel should dismiss the dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
// Dialog should be gone
expect(find.text('Move to Draft?'), findsNothing);
}
}
});
testWidgets('detail page shows product name in app bar', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first product card
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Detail page AppBar should show the product name
// The AppBar should exist and have a title
expect(find.byType(AppBar), findsOneWidget);
}
});
testWidgets('detail page back navigation returns to product list', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first product card
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Press back (the leading back button in AppBar)
final backButton = find.byType(BackButton);
if (backButton.evaluate().isNotEmpty) {
await tester.tap(backButton);
await tester.pumpAndSettle();
// Should be back on the product list with search bar
expect(find.text('Search products…'), findsOneWidget);
}
}
});
// ── Stage 6B: Mobile orders surface tests ────────────────────────
testWidgets('Orders tab shows mobile orders page with search bar', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Tap the Orders destination
await tester.tap(find.text('Orders').last);
await tester.pumpAndSettle();
// Should show the search bar
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search by customer or order ID…'), findsOneWidget);
});
testWidgets('Orders tab shows status filter chips', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Orders').last);
await tester.pumpAndSettle();
// Should show filter chips for order status categories
expect(find.widgetWithText(FilterChip, 'All'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Pending'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Shipped'), findsOneWidget);
});
testWidgets('Orders tab shows order count', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Orders').last);
await tester.pumpAndSettle();
// Fake data should produce an order count label
expect(find.textContaining('order'), findsWidgets);
});
testWidgets('tapping order card navigates to order detail page', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Orders tab
await tester.tap(find.text('Orders').last);
await tester.pumpAndSettle();
// Tap the first 160-height order card
final firstOrder = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 160 && widget.child != null,
);
if (firstOrder.evaluate().isNotEmpty) {
await tester.tap(firstOrder.first);
await tester.pumpAndSettle();
// Should now be on the detail page — AppBar shows "Order ..."
expect(find.byType(AppBar), findsOneWidget);
expect(find.textContaining('Order'), findsWidgets);
}
});
testWidgets('order detail page shows confirmation dialog for status change', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Orders tab
await tester.tap(find.text('Orders').last);
await tester.pumpAndSettle();
// Tap the first order card to navigate to detail
final firstOrder = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 160 && widget.child != null,
);
if (firstOrder.evaluate().isNotEmpty) {
await tester.tap(firstOrder.first);
await tester.pumpAndSettle();
// Scroll down to reveal the status dropdown
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -300));
await tester.pumpAndSettle();
// Look for the status dropdown and change it
final dropdown = find.byType(DropdownButtonFormField<dynamic>);
if (dropdown.evaluate().isNotEmpty) {
await tester.tap(dropdown.first);
await tester.pumpAndSettle();
// Pick a different status from the dropdown menu
final menuItems = find.byType(DropdownMenuItem<dynamic>);
if (menuItems.evaluate().length > 1) {
await tester.tap(menuItems.at(1));
await tester.pumpAndSettle();
// Confirmation dialog should appear
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('Update'), findsOneWidget);
// Cancel should dismiss the dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
}
}
}
});
testWidgets('order detail page back navigation returns to orders list', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Orders tab
await tester.tap(find.text('Orders').last);
await tester.pumpAndSettle();
// Tap the first order card
final firstOrder = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 160 && widget.child != null,
);
if (firstOrder.evaluate().isNotEmpty) {
await tester.tap(firstOrder.first);
await tester.pumpAndSettle();
// Press back
final backButton = find.byType(BackButton);
if (backButton.evaluate().isNotEmpty) {
await tester.tap(backButton);
await tester.pumpAndSettle();
// Should be back on the orders list with search bar
expect(find.text('Search by customer or order ID…'), findsOneWidget);
}
}
});
// ── Stage 6C: Mobile inventory surface tests ─────────────────────
testWidgets('Inventory tab shows mobile inventory page with search bar', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Tap the Inventory destination
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// Should show the search bar
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search by name or SKU…'), findsOneWidget);
});
testWidgets('Inventory tab shows status filter chips', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// Should show filter chips for inventory status categories
expect(find.widgetWithText(FilterChip, 'All'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'In Stock'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Low Stock'), findsOneWidget);
});
testWidgets('Inventory tab shows item count', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// Fake data should produce an item count label
expect(find.textContaining('item'), findsWidgets);
});
testWidgets('tapping inventory card navigates to inventory detail page', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Inventory tab
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// Tap the first 120-height inventory card
final firstItem = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 120 && widget.child != null,
);
if (firstItem.evaluate().isNotEmpty) {
await tester.tap(firstItem.first);
await tester.pumpAndSettle();
// Should now be on the detail page — AppBar shows the item name
expect(find.byType(AppBar), findsOneWidget);
// Detail page shows Adjust Quantity button
expect(find.text('Adjust Quantity'), findsOneWidget);
}
});
testWidgets('inventory detail page shows adjust quantity dialog', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Inventory tab
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// Tap the first inventory card to navigate to detail
final firstItem = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 120 && widget.child != null,
);
if (firstItem.evaluate().isNotEmpty) {
await tester.tap(firstItem.first);
await tester.pumpAndSettle();
// Scroll down to reveal the Adjust Quantity button
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -300));
await tester.pumpAndSettle();
// Tap Adjust Quantity
final adjustButton = find.text('Adjust Quantity');
if (adjustButton.evaluate().isNotEmpty) {
await tester.tap(adjustButton);
await tester.pumpAndSettle();
// Adjust Quantity dialog should appear
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Adjust Quantity'), findsWidgets);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('Confirm'), findsOneWidget);
// Cancel should dismiss the dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
}
}
});
testWidgets('inventory detail page back navigation returns to inventory list', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Inventory tab
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// Tap the first inventory card
final firstItem = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 120 && widget.child != null,
);
if (firstItem.evaluate().isNotEmpty) {
await tester.tap(firstItem.first);
await tester.pumpAndSettle();
// Press back
final backButton = find.byType(BackButton);
if (backButton.evaluate().isNotEmpty) {
await tester.tap(backButton);
await tester.pumpAndSettle();
// Should be back on the inventory list with search bar
expect(find.text('Search by name or SKU…'), findsOneWidget);
}
}
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

View File

@ -7,15 +7,6 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
linter:

View File

@ -1,18 +1,85 @@
/// Re-exports [KcAppConfig] and [KcAppEnvironment] from the shared `core` package.
/// Runtime configuration read from `--dart-define` values.
///
/// This file preserves backward compatibility for existing imports within
/// `kell_web`. New code should import directly from `package:core/core.dart`.
/// The app reads the following compile-time constants:
///
/// The original class names are preserved as typedefs so that existing
/// references continue to compile without changes.
library;
/// | 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;
export 'package:core/core.dart' show KcAppConfig, KcAppEnvironment;
/// WordPress / WooCommerce site URL (only used in [AppEnvironment.wordpress]).
final String wcSiteUrl;
import 'package:core/core.dart';
/// WooCommerce REST API consumer key.
final String wcConsumerKey;
/// @Deprecated('Use KcAppConfig from core instead.')
typedef AppConfig = KcAppConfig;
/// WooCommerce REST API consumer secret.
final String wcConsumerSecret;
/// @Deprecated('Use KcAppEnvironment from core instead.')
typedef AppEnvironment = KcAppEnvironment;
const AppConfig({
required this.environment,
required this.wcSiteUrl,
required this.wcConsumerKey,
required this.wcConsumerSecret,
});
/// Reads configuration from compile-time `--dart-define` constants.
factory AppConfig.fromEnvironment() {
const envString = String.fromEnvironment('KC_ENV', defaultValue: 'fake');
const siteUrl = String.fromEnvironment('KC_WC_SITE_URL');
const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY');
const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET');
final environment = AppEnvironment.fromString(envString);
return AppConfig(
environment: environment,
wcSiteUrl: siteUrl,
wcConsumerKey: consumerKey,
wcConsumerSecret: consumerSecret,
);
}
/// Whether the WordPress configuration values are all present and non-empty.
bool get hasWordPressConfig =>
wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty;
/// A human-readable label for the current environment (e.g. for badges).
String get environmentLabel => environment.label;
}
/// The supported runtime environments.
enum AppEnvironment {
/// In-memory fakes – no network calls.
fake('fake', 'FAKE'),
/// Real WooCommerce backend.
wordpress('wordpress', 'WP');
final String key;
final String label;
const AppEnvironment(this.key, this.label);
/// Parses a string into an [AppEnvironment], defaulting to [fake].
static AppEnvironment fromString(String value) {
for (final env in values) {
if (env.key == value.toLowerCase()) return env;
}
return fake;
}
}

View File

@ -1,30 +1,18 @@
/// Re-exports [KcAppScope] from the shared `core` package and provides
/// convenience accessors typed to [AppServices].
///
/// This file preserves backward compatibility for existing imports within
/// `kell_web`. New code should use [KcAppScope] from `package:core/core.dart`
/// directly.
library;
import 'package:core/core.dart';
import 'package:flutter/widgets.dart';
import 'app_config.dart';
import 'app_services.dart';
export 'package:core/core.dart' show KcAppScope;
/// 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;
/// App-specific convenience wrapper around [KcAppScope] for `kell_web`.
///
/// Preserves the original `AppScope.of(context)` and `AppScope.configOf(context)`
/// call sites so existing code continues to work without modification.
///
/// New code should prefer:
/// ```dart
/// KcAppScope.of<AppServices>(context)
/// KcAppScope.configOf<AppServices>(context)
/// ```
class AppScope extends KcAppScope<AppServices> {
const AppScope({super.key, required super.services, required super.config, required super.child});
const AppScope({super.key, required this.services, required this.config, required super.child});
/// Returns the nearest [AppServices] from the widget tree.
///
@ -35,12 +23,16 @@ class AppScope extends KcAppScope<AppServices> {
return scope!.services;
}
/// Returns the nearest [KcAppConfig] from the widget tree.
/// Returns the nearest [AppConfig] from the widget tree.
///
/// Throws if no [AppScope] ancestor is found.
static KcAppConfig configOf(BuildContext context) {
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;
}

View File

@ -1,75 +1,26 @@
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';
import 'package:integrations/integrations.dart';
import 'inventory_material_consumption_adapter.dart';
/// Holds the concrete service implementations used by `kell_web`.
///
/// Extends [KcAppServices] from the shared `core` package so that the
/// generic [KcBootstrap] and [KcAppScope] infrastructure can work with
/// this app's specific service set.
/// 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. The [AppServices.square]
/// factory wires up a Square-backed catalog and inventory repository.
/// The [AppServices.multi] factory wires up both WooCommerce and Square
/// backends simultaneously.
class AppServices extends KcAppServices {
/// 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;
/// The Square sync controller, or `null` when Square is not configured.
///
/// Present only in `square` and `multi` environments.
final SquareSyncController? squareSyncController;
/// The observability stack: structured logger, in-memory evidence buffer,
/// and the bug report service backing the shell's "Report a problem" action.
final KcObservability observability;
/// Deducts raw materials from stock when a custom order completes.
///
/// Built here rather than inside `feature_orders` because the orders and
/// inventory features must stay independent — the orders layer declares the
/// port, the composition layer supplies the adapter.
late final MaterialConsumptionPort materialConsumptionPort = InventoryMaterialConsumptionAdapter(
inventory: inventoryRepository,
logger: observability.logger,
);
AppServices({
const AppServices({
required this.inventoryRepository,
required this.ordersRepository,
required this.policyRepository,
required this.productPublishingRepository,
this.squareSyncController,
KcObservability? observability,
}) : observability = observability ?? _defaultObservability('FAKE');
/// The application version reported in bug reports.
///
/// Kept in sync with `version:` in `pubspec.yaml`.
static const String appVersion = '1.0.0+1';
/// Convenience accessor for the shared structured logger.
KcLogger get logger => observability.logger;
/// Convenience accessor for the bug report service.
KcBugReportService get bugReportService => observability.bugReportService;
static KcObservability _defaultObservability(String environmentLabel) => KcObservability.standard(
appVersion: appVersion,
environment: environmentLabel,
platform: 'web',
source: 'kell_web',
);
});
/// Creates an [AppServices] backed by fake, in-memory repositories.
factory AppServices.fake() {
@ -78,7 +29,6 @@ class AppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
observability: _defaultObservability('FAKE'),
);
}
@ -105,111 +55,6 @@ class AppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
observability: _defaultObservability('WP'),
);
}
/// Creates an [AppServices] backed by the real Square catalog and inventory
/// APIs.
///
/// [accessToken] – Square API access token (Bearer token).
/// [locationId] – Square location ID for inventory counts.
/// [environment] – `'sandbox'` or `'production'` (defaults to `'sandbox'`).
factory AppServices.square({
required String accessToken,
required String locationId,
String environment = 'sandbox',
}) {
final apiClient = environment == 'production'
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: catalogRepo,
mappingRepository: mappingRepo,
);
return AppServices(
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService),
observability: _defaultObservability('SQ'),
);
}
/// Creates an [AppServices] with both WooCommerce and Square backends active.
///
/// WooCommerce is used for the product catalog and orders. Square is used for
/// inventory counts. This mirrors the multi-channel commerce model where
/// WooCommerce is the catalog source of truth and Square tracks in-person
/// inventory.
factory AppServices.multi({
required String wcSiteUrl,
required String wcConsumerKey,
required String wcConsumerSecret,
required String squareAccessToken,
required String squareLocationId,
String squareEnvironment = 'sandbox',
}) {
final wcApiClient = WooCommerceApiClient(
siteUrl: wcSiteUrl,
consumerKey: wcConsumerKey,
consumerSecret: wcConsumerSecret,
);
final squareApiClient = squareEnvironment == 'production'
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: wcPublishingRepo,
mappingRepository: mappingRepo,
);
return AppServices(
inventoryRepository: SquareInventoryRepository(
apiClient: squareApiClient,
locationId: squareLocationId,
),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService),
observability: _defaultObservability('MULTI'),
);
}
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
///
/// Includes [createSquare] and [createMulti] callbacks so that
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
/// `KC_ENV=multi` without falling back to fake mode.
static KcServiceFactory<AppServices> get serviceFactory {
return KcServiceFactory<AppServices>(
createFake: () => AppServices.fake(),
createWordPress: (config) => AppServices.wordpress(
siteUrl: config.wcSiteUrl,
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
createSquare: (config) => AppServices.square(
accessToken: config.squareAccessToken,
locationId: config.squareLocationId,
environment: config.squareEnvironment,
),
createMulti: (config) => AppServices.multi(
wcSiteUrl: config.wcSiteUrl,
wcConsumerKey: config.wcConsumerKey,
wcConsumerSecret: config.wcConsumerSecret,
squareAccessToken: config.squareAccessToken,
squareLocationId: config.squareLocationId,
squareEnvironment: config.squareEnvironment,
),
);
}
}

View File

@ -1,33 +1,15 @@
/// Re-exports [KcBootstrap] from the shared `core` package and provides
/// a backward-compatible [Bootstrap] wrapper for `kell_web`.
///
/// This file preserves the existing `Bootstrap.run(config)` call site.
/// New code should use [KcBootstrap.run] from `package:core/core.dart`
/// with a [KcServiceFactory] directly.
library;
import 'package:core/core.dart';
import 'package:flutter/foundation.dart';
import 'app_config.dart';
import 'app_services.dart';
export 'package:core/core.dart' show KcBootstrap;
/// Backward-compatible bootstrap for `kell_web`.
/// Bootstraps [AppServices] from the runtime [AppConfig].
///
/// Delegates to [KcBootstrap.run] using the [AppServices.serviceFactory].
/// In **fake** mode the in-memory fakes are used unconditionally.
///
/// Existing call sites:
/// ```dart
/// final (:services, config: effectiveConfig) = Bootstrap.run(config);
/// ```
///
/// New code should prefer:
/// ```dart
/// final (:services, config: effectiveConfig) = KcBootstrap.run(
/// config,
/// AppServices.serviceFactory,
/// );
/// ```
/// 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._();
@ -36,7 +18,39 @@ class Bootstrap {
/// 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, KcAppConfig config}) run(KcAppConfig config) {
return KcBootstrap.run<AppServices>(config, AppServices.serviceFactory);
static ({AppServices services, AppConfig config}) run(AppConfig config) {
switch (config.environment) {
case AppEnvironment.fake:
return (services: AppServices.fake(), config: config);
case AppEnvironment.wordpress:
if (!config.hasWordPressConfig) {
if (kDebugMode) {
// ignore: avoid_print
debugPrint(
'⚠️ KC_ENV=wordpress but WooCommerce credentials are missing.\n'
' Falling back to fake mode.\n'
' Set KC_WC_SITE_URL, KC_WC_CONSUMER_KEY, and '
'KC_WC_CONSUMER_SECRET via --dart-define.',
);
}
final fallbackConfig = AppConfig(
environment: AppEnvironment.fake,
wcSiteUrl: config.wcSiteUrl,
wcConsumerKey: config.wcConsumerKey,
wcConsumerSecret: config.wcConsumerSecret,
);
return (services: AppServices.fake(), config: fallbackConfig);
}
return (
services: AppServices.wordpress(
siteUrl: config.wcSiteUrl,
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
config: config,
);
}
}
}

View File

@ -1,98 +0,0 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
/// Adapts an [InventoryRepository] to the orders layer's
/// [MaterialConsumptionPort].
///
/// This adapter lives in the app's composition layer on purpose.
/// `feature_orders` and `feature_inventory` are sibling features; neither may
/// depend on the other, or the custom order pipeline becomes coupled to a
/// stock implementation. The orders layer declares the narrow port it needs
/// and the app decides which repository satisfies it — the same arrangement
/// already used for cross-feature navigation callbacks.
///
/// ## Failure behaviour
///
/// Deduction is **tolerant by contract**: one material failing must not abort
/// the rest, and nothing here may throw. A missing SKU, an unsuccessful
/// adjustment, and an unexpected exception all become entries in
/// [MaterialDeductionResult.failures] so the operator sees exactly which
/// material could not be decremented while the commission still completes.
class InventoryMaterialConsumptionAdapter implements MaterialConsumptionPort {
final InventoryRepository _inventory;
/// Structured logger for deduction failures.
///
/// Defaults to [KcLogger.silent] so tests need not supply one. Log context
/// carries SKUs, order IDs, and quantities only — never customer identity,
/// money amounts, or payment identifiers.
final KcLogger logger;
InventoryMaterialConsumptionAdapter({required InventoryRepository inventory, KcLogger? logger})
: _inventory = inventory,
logger = (logger ?? KcLogger.silent()).forSource(_logSource);
static const String _logSource = 'InventoryMaterialConsumptionAdapter';
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
final deducted = <MaterialUsage>[];
final failures = <String, String>{};
for (final usage in usages) {
if (!usage.isValid) {
failures[usage.sku] = 'Invalid material record.';
continue;
}
try {
final item = await _inventory.getItemBySku(usage.sku);
if (item == null) {
failures[usage.sku] = 'No inventory item with this SKU.';
logger.warning(
'Material deduction skipped: SKU not in inventory.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
);
continue;
}
final result = await _inventory.adjustQuantity(
item.id,
-usage.quantity,
'Custom order $orderId materials',
);
if (result.success) {
deducted.add(usage);
} else {
failures[usage.sku] = result.errorMessage ?? 'Adjustment rejected.';
logger.warning(
'Material deduction rejected by inventory.',
context: {
'operation': 'deductMaterials',
'orderId': orderId,
'sku': usage.sku,
'quantity': usage.quantity,
},
);
}
} catch (e, stackTrace) {
// Tolerant by contract: record the failure and keep going so one bad
// SKU cannot abandon the remaining deductions.
failures[usage.sku] = 'Inventory error: $e';
logger.error(
'Unexpected error while deducting a material.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
error: e,
stackTrace: stackTrace,
);
}
}
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}

View File

@ -129,9 +129,6 @@ class DashboardSummary {
case OrderStatus.delivered:
deliveredRevenue += order.total;
case OrderStatus.cancelled:
case OrderStatus.onHold:
case OrderStatus.refunded:
case OrderStatus.failed:
break;
}
}

View File

@ -1,32 +1,13 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'app.dart';
import 'composition/app_config.dart';
import 'composition/app_scope.dart';
import 'composition/app_services.dart';
import 'composition/bootstrap.dart';
void main() {
final config = AppConfig.fromEnvironment();
final (:services, config: effectiveConfig) = Bootstrap.run(config);
// Route uncaught framework and platform errors into the structured logger
// before the first frame, so startup failures are captured too.
KcErrorReporter.install(services.logger);
services.logger.info(
'Application starting',
source: 'main',
context: {
'environment': effectiveConfig.environment.key,
'requestedEnvironment': config.environment.key,
'fellBackToFake': config.environment != effectiveConfig.environment,
'appVersion': AppServices.appVersion,
},
);
KcErrorReporter.runGuarded(services.logger, () {
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
});
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
}

View File

@ -77,37 +77,6 @@ abstract final class AppNavigation {
);
}
// ── Orders → Custom order intake ──────────────────────────────────────
/// Orders page "New Custom Order" action → the custom order intake form.
static void ordersToCustomOrder(BuildContext context) {
navigateTo(context, NavigationTarget(route: AppRoutes.customOrder));
}
/// Custom order confirmation → Orders page, pre-selecting the new order.
static void customOrderToOrders(BuildContext context, {required String orderId}) {
navigateTo(
context,
NavigationTarget(route: AppRoutes.orders, arguments: {'selectedId': orderId}),
);
}
// ── Orders → Custom order tracking ────────────────────────────────────
/// Orders page "Track Custom Orders" action → the tracking workspace.
///
/// [stage] is a `FulfillmentStatus.wireValue`; an absent or unrecognised
/// value leaves the tracking page unfiltered.
static void ordersToCustomOrderTracking(BuildContext context, {String? stage, String? orderId}) {
navigateTo(
context,
NavigationTarget(
route: AppRoutes.customOrderTracking,
arguments: {'stage': ?stage, 'selectedId': ?orderId},
),
);
}
// ── Products → Policy ─────────────────────────────────────────────────
/// Product draft → Policy page filtered to "Product Compliance".

View File

@ -5,6 +5,9 @@ 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.
///
@ -65,11 +68,11 @@ class _DashboardPageState extends State<DashboardPage> {
return ListView(
children: [
const KcSectionHeader(title: 'Overview'),
const SectionHeader(title: 'Overview'),
const SizedBox(height: KcSpacing.sm),
_buildSummaryGrid(context, summary),
const SizedBox(height: KcSpacing.xl),
KcSectionHeader(
SectionHeader(
title: 'Quick Actions',
action: TextButton(
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory),
@ -79,9 +82,9 @@ class _DashboardPageState extends State<DashboardPage> {
const SizedBox(height: KcSpacing.sm),
_buildQuickActions(context),
const SizedBox(height: KcSpacing.xl),
const KcSectionHeader(title: 'Recent Activity'),
const SectionHeader(title: 'Recent Activity'),
const SizedBox(height: KcSpacing.sm),
const KcEmptyState(
const EmptyStatePanel(
icon: Icons.history,
message:
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
@ -105,56 +108,56 @@ class _DashboardPageState extends State<DashboardPage> {
}
final cards = [
KcSummaryCard(
SummaryCard(
icon: Icons.inventory_2,
iconColor: KcColors.denimBlue,
label: 'Total Products',
value: '${summary.totalProducts}',
onTap: () => AppNavigation.dashboardToInventory(context),
),
KcSummaryCard(
SummaryCard(
icon: Icons.check_circle_outline,
iconColor: KcColors.success,
label: 'In Stock',
value: '${summary.inStock}',
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'),
),
KcSummaryCard(
SummaryCard(
icon: Icons.warning_amber_rounded,
iconColor: KcColors.warning,
label: 'Low Stock',
value: '${summary.lowStock}',
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'),
),
KcSummaryCard(
SummaryCard(
icon: Icons.edit_note,
iconColor: KcColors.neutral,
label: 'Draft',
value: '${summary.draftProducts}',
onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'),
),
KcSummaryCard(
SummaryCard(
icon: Icons.receipt_long,
iconColor: KcColors.denimBlue,
label: 'Total Orders',
value: '${summary.totalOrders}',
onTap: () => AppNavigation.dashboardToOrders(context),
),
KcSummaryCard(
SummaryCard(
icon: Icons.hourglass_empty,
iconColor: KcColors.warning,
label: 'Pending Orders',
value: '${summary.pendingOrders}',
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'),
),
KcSummaryCard(
SummaryCard(
icon: Icons.local_shipping_outlined,
iconColor: KcColors.success,
label: 'Active Orders',
value: '${summary.activeOrders}',
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'),
),
KcSummaryCard(
SummaryCard(
icon: Icons.attach_money,
iconColor: KcColors.success,
label: 'Revenue',

View File

@ -1,56 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
/// The Integrations page for `kell_web`.
///
/// When a [SquareSyncController] is provided (i.e. the app is running in
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
/// operator can trigger and monitor Square catalog + inventory syncs.
///
/// When no controller is available (fake or wordpress mode), a placeholder
/// message is shown instead.
class IntegrationsPage extends StatelessWidget {
/// The Square sync controller, or `null` when Square is not configured.
final SquareSyncController? squareSyncController;
const IntegrationsPage({super.key, this.squareSyncController});
@override
Widget build(BuildContext context) {
final controller = squareSyncController;
if (controller == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text('No integrations configured', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Set KC_ENV=square or KC_ENV=multi with Square credentials\n'
'to enable the Square sync panel.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
],
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Square',
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
SquareSyncStatusPanel(controller: controller),
],
),
);
}
}

View File

@ -10,7 +10,7 @@ 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_page.dart';
import '../pages/integrations_placeholder_page.dart';
import '../shell/app_shell.dart';
abstract final class AppRoutes {
@ -18,8 +18,6 @@ abstract final class AppRoutes {
static const String inventory = '/inventory';
static const String products = '/products';
static const String orders = '/orders';
static const String customOrder = '/orders/custom';
static const String customOrderTracking = '/orders/tracking';
static const String finance = '/finance';
static const String policy = '/policy';
static const String integrations = '/integrations';
@ -85,41 +83,12 @@ abstract final class AppRoutes {
repository: AppScope.of(context).ordersRepository,
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context),
onTrackCustomOrders: () => AppNavigation.ordersToCustomOrderTracking(context),
initialFilter: args['filter'],
initialQuery: args['query'],
initialSelectedId: args['selectedId'],
),
),
);
case customOrder:
return _buildRoute(
settings,
(context) => AppShell(
selectedRoute: orders,
title: 'New Custom Order',
child: CustomOrderFormPage(
repository: AppScope.of(context).ordersRepository,
onComplete: (order) => AppNavigation.customOrderToOrders(context, orderId: order.id),
),
),
);
case customOrderTracking:
return _buildRoute(settings, (context) {
final services = AppScope.of(context);
return AppShell(
selectedRoute: orders,
title: 'Custom Order Tracking',
child: CustomOrderTrackingPage(
repository: services.ordersRepository,
materialPort: services.materialConsumptionPort,
onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context),
initialStage: _parseStage(args['stage']),
initialSelectedId: args['selectedId'],
),
);
});
case finance:
return _buildRoute(
settings,
@ -148,12 +117,10 @@ abstract final class AppRoutes {
case integrations:
return _buildRoute(
settings,
(context) => AppShell(
(context) => const AppShell(
selectedRoute: integrations,
title: 'Integrations',
child: IntegrationsPage(
squareSyncController: AppScope.of(context).squareSyncController,
),
child: IntegrationsPlaceholderPage(),
),
);
default:
@ -178,20 +145,6 @@ abstract final class AppRoutes {
return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder);
}
/// Parses a fulfillment stage from a route argument.
///
/// Returns `null` for an absent or unrecognised value so the tracking page
/// falls back to "all stages" rather than silently showing an arbitrary
/// filter. [FulfillmentStatus.fromWire] defaults to `quoted`, which is the
/// wrong behaviour for a filter, so the lookup is done explicitly here.
static FulfillmentStatus? _parseStage(String? value) {
if (value == null || value.isEmpty) return null;
for (final stage in FulfillmentStatus.values) {
if (stage.wireValue == value) return stage;
}
return null;
}
/// Safely extracts the `Map<String, String>` arguments from [RouteSettings].
///
/// Returns an empty map when no arguments are present or the type doesn't

View File

@ -1,5 +1,3 @@
import 'package:core/core.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../composition/app_config.dart';
@ -27,7 +25,6 @@ class AppShell extends StatelessWidget {
appBar: AppBar(
title: const Text('Kell Creations'),
actions: [
_ReportProblemAction(screen: title),
_EnvironmentBadge(environment: config.environment),
const SizedBox(width: 12),
],
@ -149,53 +146,6 @@ class AppShell extends StatelessWidget {
}
}
/// An [AppBar] action that opens the shared bug report dialog and submits the
/// resulting draft through [AppServices.bugReportService].
///
/// The recent log tail is attached automatically as diagnostic evidence, and
/// credentials are redacted by `KcRedactor` before anything is recorded.
class _ReportProblemAction extends StatelessWidget {
/// The screen the operator is reporting from.
final String screen;
const _ReportProblemAction({required this.screen});
Future<void> _report(BuildContext context) async {
final services = AppScope.of(context);
final messenger = ScaffoldMessenger.of(context);
final draft = await KcBugReportDialog.show(context, screen: screen);
if (draft == null) return;
final result = await services.bugReportService.submit(
title: draft.title,
description: draft.description,
severity: KcBugSeverity.fromString(draft.severityName),
reporter: draft.reporter,
screen: screen,
);
messenger.showSnackBar(
SnackBar(
content: Text(
result.succeeded
? 'Report ${result.report!.id} submitted. Thank you.'
: result.errorMessage ?? 'Could not submit the report.',
),
),
);
}
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.bug_report_outlined),
tooltip: 'Report a problem',
onPressed: () => _report(context),
);
}
}
/// A small coloured chip displayed in the [AppBar] that shows the current
/// runtime environment (e.g. "FAKE" or "WP").
class _EnvironmentBadge extends StatelessWidget {
@ -215,12 +165,6 @@ class _EnvironmentBadge extends StatelessWidget {
case AppEnvironment.wordpress:
backgroundColor = Colors.green.shade100;
foregroundColor = Colors.green.shade900;
case AppEnvironment.square:
backgroundColor = Colors.blue.shade100;
foregroundColor = Colors.blue.shade900;
case AppEnvironment.multi:
backgroundColor = Colors.purple.shade100;
foregroundColor = Colors.purple.shade900;
}
return Center(

View File

@ -1,15 +1,38 @@
/// Re-exports [KcEmptyState] from the shared design system.
///
/// This file preserves backward compatibility for existing imports.
/// New code should import directly from `package:design_system/design_system.dart`.
///
/// The original `EmptyStatePanel` class name is preserved as a typedef
/// so that existing references continue to compile without changes.
library;
export 'package:design_system/design_system.dart' show KcEmptyState;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// @Deprecated('Use KcEmptyState from design_system instead.')
typedef EmptyStatePanel = KcEmptyState;
/// 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!],
],
),
),
),
);
}
}

View File

@ -1,15 +1,27 @@
/// Re-exports [KcSectionHeader] from the shared design system.
///
/// This file preserves backward compatibility for existing imports.
/// New code should import directly from `package:design_system/design_system.dart`.
///
/// The original `SectionHeader` class name is preserved as a typedef
/// so that existing references continue to compile without changes.
library;
export 'package:design_system/design_system.dart' show KcSectionHeader;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// @Deprecated('Use KcSectionHeader from design_system instead.')
typedef SectionHeader = KcSectionHeader;
/// 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,
],
),
);
}
}

View File

@ -1,15 +1,52 @@
/// Re-exports [KcSummaryCard] from the shared design system.
///
/// This file preserves backward compatibility for existing imports.
/// New code should import directly from `package:design_system/design_system.dart`.
///
/// The original `SummaryCard` class name is preserved as a typedef
/// so that existing references continue to compile without changes.
library;
export 'package:design_system/design_system.dart' show KcSummaryCard;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// @Deprecated('Use KcSummaryCard from design_system instead.')
typedef SummaryCard = KcSummaryCard;
/// 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),
);
}
}

View File

@ -133,13 +133,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
integrations:
dependency: "direct main"
description:
path: "../../packages/integrations"
relative: true
source: path
version: "0.0.1"
leak_tracker:
dependency: transitive
description:
@ -176,10 +169,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.20"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@ -192,10 +185,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.17.0"
path:
dependency: transitive
description:
@ -253,10 +246,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.10"
typed_data:
dependency: transitive
description:
@ -269,18 +262,18 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
version: "15.0.2"
web:
dependency: transitive
description:
@ -290,5 +283,5 @@ packages:
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.11.0 <4.0.0"
dart: ">=3.11.4 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ^3.11.0
sdk: ^3.11.4
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@ -45,8 +45,6 @@ dependencies:
path: ../../packages/feature_orders
feature_policy:
path: ../../packages/feature_policy
integrations:
path: ../../packages/integrations
feature_wordpress:
path: ../../packages/feature_wordpress

View File

@ -1,248 +0,0 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/composition/app_services.dart';
void main() {
group('AppServices', () {
// ── fake factory ──────────────────────────────────────────────────────────
group('fake()', () {
test('creates AppServices instance', () {
final services = AppServices.fake();
expect(services, isA<AppServices>());
expect(services, isA<KcAppServices>());
});
test('inventoryRepository is FakeInventoryRepository', () {
final services = AppServices.fake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
test('productPublishingRepository is FakeProductPublishingRepository', () {
final services = AppServices.fake();
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
});
});
// ── wordpress factory ─────────────────────────────────────────────────────
group('wordpress()', () {
test('creates AppServices instance', () {
final services = AppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services, isA<AppServices>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = AppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
final services = AppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// ── square factory ────────────────────────────────────────────────────────
group('square()', () {
test('creates AppServices instance', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<AppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is SquareCatalogRepository', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('squareSyncController is non-null', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.squareSyncController, isNotNull);
expect(services.squareSyncController, isA<SquareSyncController>());
services.squareSyncController!.dispose();
});
test('defaults to sandbox environment', () {
// Constructing with no environment param should not throw.
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<AppServices>());
});
test('accepts production environment', () {
final services = AppServices.square(
accessToken: 'EAAAl_test',
locationId: 'LOC123',
environment: 'production',
);
expect(services, isA<AppServices>());
});
});
// ── multi factory ─────────────────────────────────────────────────────────
group('multi()', () {
test('creates AppServices instance', () {
final services = AppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services, isA<AppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = AppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = AppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
});
// ── serviceFactory ────────────────────────────────────────────────────────
group('serviceFactory', () {
test('createFake returns fake services', () {
final factory = AppServices.serviceFactory;
final services = factory.createFake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
expect(services.squareSyncController, isNull);
});
test('createWordPress returns wordpress services', () {
final factory = AppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.wordpress,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
);
final services = factory.createWordPress(config);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('createSquare is non-null', () {
final factory = AppServices.serviceFactory;
expect(factory.createSquare, isNotNull);
});
test('createSquare returns square services', () {
final factory = AppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createSquare!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('createMulti is non-null', () {
final factory = AppServices.serviceFactory;
expect(factory.createMulti, isNotNull);
});
test('createMulti returns multi services', () {
final factory = AppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createMulti!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, AppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.square);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, AppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.multi);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
expect(
result.services.productPublishingRepository,
isA<WordPressProductPublishingRepository>(),
);
});
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: '',
squareLocationId: '',
);
final result = KcBootstrap.run(config, AppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.fake);
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
});
}

View File

@ -14,31 +14,6 @@ class _StubInventoryRepository implements InventoryRepository {
@override
Future<List<InventoryItem>> getInventoryItems() async => items;
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) =>
throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) =>
throw UnimplementedError();
@override
Future<InventoryItem> createItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryItem> updateItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
throw UnimplementedError();
@override
Future<InventoryItem?> getItemBySku(String sku) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> syncWithRemote(String id, int remoteQuantity, String reason) =>
throw UnimplementedError();
}
class _StubProductPublishingRepository implements ProductPublishingRepository {
@ -54,55 +29,6 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductPrice(String id, double price) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductDescription(String id, String description) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductCategory(String id, String category) =>
throw UnimplementedError();
// Stage 9C additions
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) => throw UnimplementedError();
@override
Future<void> deleteProduct(String id, {bool force = false}) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) =>
throw UnimplementedError();
@override
Future<List<ProductCategory>> getCategories() => throw UnimplementedError();
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) =>
throw UnimplementedError();
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) =>
throw UnimplementedError();
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) => throw UnimplementedError();
@override
Future<List<ProductImage>> getProductImages(String id) => throw UnimplementedError();
}
class _StubOrdersRepository implements OrdersRepository {
@ -111,108 +37,11 @@ class _StubOrdersRepository implements OrdersRepository {
@override
Future<List<Order>> getOrders() async => orders;
@override
Future<Order?> getOrder(String id) async => orders.where((o) => o.id == id).firstOrNull;
@override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) =>
throw UnimplementedError();
@override
Future<OrderActionResult> addOrderNote(String id, String note) => throw UnimplementedError();
@override
Future<OrderActionResult> createOrder(Order order) => throw UnimplementedError();
@override
Future<OrderActionResult> createCustomOrder(
Order order, {
required CustomOrderSpec spec,
DepositInfo? deposit,
}) => throw UnimplementedError();
@override
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) =>
throw UnimplementedError();
@override
Future<OrderActionResult> recordDeposit(
String id, {
required int amountCents,
required DateTime paidAt,
int? totalAmountCents,
}) => throw UnimplementedError();
@override
Future<OrderActionResult> advanceFulfillmentStatus(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
}) => throw UnimplementedError();
@override
Future<OrderActionResult> recordMaterialUsage(String id, List<MaterialUsage> usages) =>
throw UnimplementedError();
}
class _FailingInventoryRepository implements InventoryRepository {
@override
Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error');
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) =>
throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) =>
throw UnimplementedError();
@override
Future<InventoryItem> createItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryItem> updateItem(InventoryItem item) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
throw UnimplementedError();
@override
Future<InventoryItem?> getItemBySku(String sku) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> syncWithRemote(String id, int remoteQuantity, String reason) =>
throw UnimplementedError();
}
// ── Shared test helpers ────────────────────────────────────────────────────
final _baseDate = DateTime(2026, 7, 1);
InventoryItem _inv({
required String id,
required String sku,
required String name,
required int quantityOnHand,
required double unitPrice,
required InventoryStatus status,
int reorderPoint = 5,
}) {
return InventoryItem(
id: id,
sku: sku,
name: name,
description: '',
imageUrl: '',
category: 'Test',
quantityOnHand: quantityOnHand,
unitPrice: unitPrice,
reorderPoint: reorderPoint,
status: status,
lastUpdated: _baseDate,
);
}
// ── Tests ──────────────────────────────────────────────────────────────────
@ -235,7 +64,7 @@ void main() {
test('load() sets isLoading then populates summary', () async {
final inventoryItems = [
_inv(
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',
@ -243,7 +72,7 @@ void main() {
unitPrice: 5.0,
status: InventoryStatus.inStock,
),
_inv(
const InventoryItem(
id: '2',
sku: 'B',
name: 'B',
@ -295,7 +124,7 @@ void main() {
test('load() aggregates data from all three repositories', () async {
final inventoryItems = [
_inv(
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',

View File

@ -4,34 +4,6 @@ import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/dashboard/domain/dashboard_summary.dart';
// ── Shared test helpers ────────────────────────────────────────────────────
final _baseDate = DateTime(2026, 7, 1);
InventoryItem _inv({
required String id,
required String sku,
required String name,
required int quantityOnHand,
required double unitPrice,
required InventoryStatus status,
int reorderPoint = 5,
}) {
return InventoryItem(
id: id,
sku: sku,
name: name,
description: '',
imageUrl: '',
category: 'Test',
quantityOnHand: quantityOnHand,
unitPrice: unitPrice,
reorderPoint: reorderPoint,
status: status,
lastUpdated: _baseDate,
);
}
void main() {
group('DashboardSummary.empty', () {
test('has all zeroes', () {
@ -51,7 +23,7 @@ void main() {
group('DashboardSummary.fromData', () {
test('counts inventory statuses correctly', () {
final items = [
_inv(
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',
@ -59,7 +31,7 @@ void main() {
unitPrice: 5.0,
status: InventoryStatus.inStock,
),
_inv(
const InventoryItem(
id: '2',
sku: 'B',
name: 'B',
@ -67,7 +39,7 @@ void main() {
unitPrice: 5.0,
status: InventoryStatus.lowStock,
),
_inv(
const InventoryItem(
id: '3',
sku: 'C',
name: 'C',
@ -75,7 +47,7 @@ void main() {
unitPrice: 5.0,
status: InventoryStatus.outOfStock,
),
_inv(
const InventoryItem(
id: '4',
sku: 'D',
name: 'D',
@ -205,9 +177,10 @@ void main() {
});
test('computes full summary from fake repository data', () {
// Use the same data the fake repositories return.
final summary = DashboardSummary.fromData(
inventoryItems: [
_inv(
inventoryItems: const [
InventoryItem(
id: '1',
sku: 'BC-FLR-001',
name: 'Floral Bowl Cozy',
@ -215,16 +188,15 @@ void main() {
unitPrice: 12.99,
status: InventoryStatus.inStock,
),
_inv(
InventoryItem(
id: '2',
sku: 'CS-CIT-002',
name: 'Citrus Coaster Set',
quantityOnHand: 7,
unitPrice: 16.50,
status: InventoryStatus.lowStock,
reorderPoint: 10,
),
_inv(
InventoryItem(
id: '3',
sku: 'NL-OCN-003',
name: 'Ocean Nightlight',
@ -232,7 +204,7 @@ void main() {
unitPrice: 19.99,
status: InventoryStatus.outOfStock,
),
_inv(
InventoryItem(
id: '4',
sku: 'JG-BLU-004',
name: 'Fabric Jar Gripper',
@ -240,7 +212,7 @@ void main() {
unitPrice: 8.50,
status: InventoryStatus.inStock,
),
_inv(
InventoryItem(
id: '5',
sku: 'SH-SUN-005',
name: 'Skillet Handle Sleeve',
@ -248,7 +220,7 @@ void main() {
unitPrice: 10.99,
status: InventoryStatus.lowStock,
),
_inv(
InventoryItem(
id: '6',
sku: 'SC-SUB-006',
name: 'Sublimated Slate Coaster',

View File

@ -1,53 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/pages/integrations_page.dart';
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
SquareSyncController _fakeController() {
final syncService = SquareCatalogSyncService(
catalogRepository: FakeProductPublishingRepository(),
mappingRepository: FakeProductIdMappingRepository(),
);
return SquareSyncController(syncService: syncService);
}
void main() {
group('IntegrationsPage', () {
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
await tester.pumpWidget(_wrap(const IntegrationsPage()));
await tester.pump();
expect(find.text('No integrations configured'), findsOneWidget);
});
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square Sync'), findsOneWidget);
expect(find.text('Sync Now'), findsOneWidget);
});
testWidgets('shows Square section label when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square'), findsOneWidget);
});
testWidgets('placeholder shows hub icon', (tester) async {
await tester.pumpWidget(_wrap(const IntegrationsPage()));
await tester.pump();
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
});
});
}

View File

@ -1,76 +0,0 @@
import 'dart:async';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/app.dart';
import 'package:kell_web/composition/app_config.dart';
import 'package:kell_web/composition/app_scope.dart';
import 'package:kell_web/composition/app_services.dart';
import 'package:kell_web/routing/app_routes.dart';
Widget _buildTestApp() {
const config = AppConfig(
environment: AppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
);
return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp());
}
/// Navigates the running app to [route].
Future<void> _go(WidgetTester tester, String route) async {
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(navigator.pushReplacementNamed(route));
await tester.pumpAndSettle();
}
void main() {
group('AppRoutes — custom order route', () {
test('exposes the custom order path', () {
expect(AppRoutes.customOrder, '/orders/custom');
});
testWidgets('the route renders the custom order form inside the shell', (tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await _go(tester, AppRoutes.customOrder);
expect(find.byType(CustomOrderFormPage), findsOneWidget);
expect(find.text('New Custom Order'), findsWidgets);
});
testWidgets('the route renders the intake sections', (tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await _go(tester, AppRoutes.customOrder);
expect(find.text('Customer'), findsOneWidget);
expect(find.text('Commission'), findsOneWidget);
expect(find.text('Pricing'), findsOneWidget);
});
testWidgets('the orders route offers the custom order action', (tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await _go(tester, AppRoutes.orders);
expect(find.text('New Custom Order'), findsOneWidget);
});
testWidgets('tapping the orders action opens the intake form', (tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
await _go(tester, AppRoutes.orders);
await tester.tap(find.text('New Custom Order'));
await tester.pumpAndSettle();
expect(find.byType(CustomOrderFormPage), findsOneWidget);
});
});
}

View File

@ -1,157 +0,0 @@
import 'dart:async';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/app.dart';
import 'package:kell_web/composition/app_config.dart';
import 'package:kell_web/composition/app_scope.dart';
import 'package:kell_web/composition/app_services.dart';
import 'package:kell_web/routing/app_routes.dart';
Widget _buildTestApp() {
const config = AppConfig(
environment: AppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
);
return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp());
}
/// Navigates the running app to [route] with optional arguments.
Future<void> _go(WidgetTester tester, String route, {Map<String, String>? args}) async {
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(navigator.pushReplacementNamed(route, arguments: args));
await tester.pumpAndSettle();
}
Future<void> _boot(WidgetTester tester) async {
tester.view.physicalSize = const Size(1600, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
}
void main() {
group('AppRoutes — custom order tracking route', () {
test('exposes the tracking path', () {
expect(AppRoutes.customOrderTracking, '/orders/tracking');
});
testWidgets('renders the tracking page inside the shell', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
expect(find.text('Custom Order Tracking'), findsOneWidget);
});
testWidgets('lists the seeded commissions', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('renders the stage filter chips', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.text('All (5)'), findsOneWidget);
expect(find.text('Quoted (1)'), findsOneWidget);
});
testWidgets('renders the timeline for the auto-selected commission', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.text('Fulfillment Timeline'), findsOneWidget);
expect(find.byType(FulfillmentTimeline), findsOneWidget);
});
testWidgets('applies a stage argument as the initial filter', (tester) async {
await _boot(tester);
await _go(
tester,
AppRoutes.customOrderTracking,
args: {'stage': FulfillmentStatus.inProduction.wireValue},
);
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
testWidgets('ignores an unrecognised stage argument', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking, args: {'stage': 'not-a-stage'});
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('pre-selects a commission from selectedId', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking, args: {'selectedId': 'KC-C2004'});
expect(find.text('KC-C2004'), findsWidgets);
});
});
group('AppRoutes — orders page tracking entry point', () {
testWidgets('the orders route offers the tracking action', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.orders);
expect(find.text('Track Custom Orders'), findsOneWidget);
});
testWidgets('tapping the tracking action opens the workspace', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.orders);
await tester.tap(find.text('Track Custom Orders'));
await tester.pumpAndSettle();
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
});
testWidgets('the tracking page offers the intake action', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.text('New Custom Order'), findsWidgets);
});
testWidgets('the intake action from tracking opens the form', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
await tester.tap(find.text('New Custom Order').first);
await tester.pumpAndSettle();
expect(find.byType(CustomOrderFormPage), findsOneWidget);
});
});
group('AppServices — material consumption port', () {
test('exposes a port wired to the inventory repository', () {
expect(AppServices.fake().materialConsumptionPort, isA<MaterialConsumptionPort>());
});
test('returns the same port instance on repeated access', () {
final services = AppServices.fake();
expect(identical(services.materialConsumptionPort, services.materialConsumptionPort), isTrue);
});
});
}

View File

@ -1,163 +0,0 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/app.dart';
import 'package:kell_web/composition/app_config.dart';
import 'package:kell_web/composition/app_scope.dart';
import 'package:kell_web/composition/app_services.dart';
/// Pumps the web app with fake services so the shell's bug report action can
/// be exercised end-to-end.
Future<AppServices> _pumpApp(WidgetTester tester) async {
final services = AppServices.fake();
await tester.pumpWidget(
AppScope(
services: services,
config: const AppConfig(
environment: AppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
),
child: const KellWebApp(),
),
);
await tester.pumpAndSettle();
return services;
}
/// Matches the Nth [TextField] inside the open bug report dialog.
///
/// Scoped to the dialog because feature pages behind it contribute their own
/// text fields.
Finder _dialogField(int index) =>
find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)).at(index);
void main() {
group('AppServices observability wiring', () {
test('fake factory builds a usable observability stack', () {
final services = AppServices.fake();
expect(services.observability, isNotNull);
expect(services.logger, same(services.observability.logger));
expect(services.bugReportService, same(services.observability.bugReportService));
});
test('logger and bug report service share the same evidence buffer', () async {
final services = AppServices.fake();
services.logger.error('Publish failed', context: {'productId': '42'});
final result = await services.bugReportService.submit(
title: 'Publishing broken',
description: 'Publish button reports success but nothing changes.',
);
expect(result.succeeded, isTrue);
expect(result.report!.hasFailureEvidence, isTrue);
expect(
result.report!.logTail.any((r) => r.message == 'Publish failed'),
isTrue,
reason: 'the log tail must come from the same buffer the logger writes to',
);
});
test('stamps the web platform and app version', () async {
final services = AppServices.fake();
final result = await services.bugReportService.submit(title: 'T', description: 'D');
expect(result.report!.platform, 'web');
expect(result.report!.appVersion, AppServices.appVersion);
});
test('wordpress factory labels reports with the WP environment', () async {
final services = AppServices.wordpress(
siteUrl: 'https://example.com',
consumerKey: 'ck',
consumerSecret: 'cs',
);
final result = await services.bugReportService.submit(title: 'T', description: 'D');
expect(result.report!.environment, 'WP');
});
test('multi factory labels reports with the MULTI environment', () async {
final services = AppServices.multi(
wcSiteUrl: 'https://example.com',
wcConsumerKey: 'ck',
wcConsumerSecret: 'cs',
squareAccessToken: 'tok',
squareLocationId: 'loc',
);
final result = await services.bugReportService.submit(title: 'T', description: 'D');
expect(result.report!.environment, 'MULTI');
});
});
group('AppShell report a problem action', () {
testWidgets('exposes a bug report action in the app bar', (tester) async {
await _pumpApp(tester);
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
expect(find.byTooltip('Report a problem'), findsOneWidget);
});
testWidgets('opens the shared bug report dialog with the screen name', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
expect(find.text('Report a problem'), findsOneWidget);
expect(find.textContaining('Screen: '), findsOneWidget);
});
testWidgets('submits the report and confirms via snack bar', (tester) async {
final services = await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
await tester.enterText(_dialogField(0), 'Dashboard blank');
await tester.enterText(_dialogField(1), 'No KPI cards render.');
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(find.textContaining('submitted. Thank you.'), findsOneWidget);
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
expect(sink.reports, hasLength(1));
expect(sink.reports.single.title, 'Dashboard blank');
expect(sink.reports.single.screen, isNotNull);
});
testWidgets('cancelling submits nothing', (tester) async {
final services = await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
expect(sink.reports, isEmpty);
expect(find.textContaining('submitted'), findsNothing);
});
testWidgets('validation blocks submission of an empty report', (tester) async {
final services = await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(find.text('A title is required.'), findsOneWidget);
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
expect(sink.reports, isEmpty);
});
});
}

View File

@ -1,204 +0,0 @@
# Kell Creations — Cross-Platform Shell Composition Strategy
## Overview
This document describes the shared composition pattern that all Kell Creations
app targets (`kell_web`, `kell_mobile`, future targets) must follow. The pattern
lives in the `core` package and provides a consistent way to:
1. Read runtime configuration from `--dart-define` values.
2. Construct environment-appropriate services (fake vs. real backends).
3. Expose services and configuration to the widget tree.
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ core (shared package) │
│ │
│ KcAppConfig / KcAppEnvironment │
│ └─ Runtime config from --dart-define │
│ │
│ KcAppServices (abstract) │
│ └─ Base class for app service containers │
│ │
│ KcServiceFactory<T extends KcAppServices> │
│ └─ Generic factory: createFake() / createWordPress(cfg) │
│ │
│ KcBootstrap │
│ └─ Shared bootstrap: env switch + WP credential fallback │
│ │
│ KcAppScope<T extends KcAppServices> │
│ └─ InheritedWidget exposing T + KcAppConfig to tree │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ │
┌────────┴───────────┐ ┌────────────┴──────────────┐
│ kell_web (app) │ │ kell_mobile (app) │
│ │ │ │
│ AppServices │ │ MobileAppServices │
│ extends │ │ extends │
│ KcAppServices │ │ KcAppServices │
│ │ │ │
│ AppScope │ │ (uses KcAppScope │
│ extends │ │ <MobileAppServices>) │
│ KcAppScope │ │ │
│ <AppServices> │ │ │
│ │ │ │
│ Bootstrap │ │ (uses KcBootstrap.run │
│ delegates to │ │ with own factory) │
│ KcBootstrap.run │ │ │
└─────────────────────┘ └────────────────────────────┘
```
## Why abstract services?
Concrete service containers hold references to feature-package repository types
(e.g., `InventoryRepository`, `ProductPublishingRepository`). Moving those
references into `core` would create **circular dependencies**:
```
core → feature_wordpress → core ← NOT ALLOWED
```
By keeping `KcAppServices` abstract in `core`, the package owns the composition
**contract** without knowing concrete types. Each app provides its own concrete
subclass.
## Contract for new app targets
When creating a new app target (e.g., `kell_mobile`), follow this pattern:
### 1. Create a concrete services class
```dart
// apps/kell_mobile/lib/composition/mobile_app_services.dart
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
// ... other feature imports
class MobileAppServices extends KcAppServices {
final InventoryRepository inventoryRepository;
final ProductPublishingRepository productPublishingRepository;
// ... other repositories as needed
const MobileAppServices({
required this.inventoryRepository,
required this.productPublishingRepository,
});
factory MobileAppServices.fake() {
return MobileAppServices(
inventoryRepository: FakeInventoryRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
);
}
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(),
productPublishingRepository:
WordPressProductPublishingRepository(apiClient: apiClient),
);
}
static KcServiceFactory<MobileAppServices> get serviceFactory {
return KcServiceFactory<MobileAppServices>(
createFake: () => MobileAppServices.fake(),
createWordPress: (config) => MobileAppServices.wordpress(
siteUrl: config.wcSiteUrl,
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
);
}
}
```
### 2. Wire up main.dart
```dart
// apps/kell_mobile/lib/main.dart
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'composition/mobile_app_services.dart';
void main() {
final config = KcAppConfig.fromEnvironment();
final (:services, config: effectiveConfig) = KcBootstrap.run(
config,
MobileAppServices.serviceFactory,
);
runApp(
KcAppScope<MobileAppServices>(
services: services,
config: effectiveConfig,
child: const KellMobileApp(),
),
);
}
```
### 3. Access services in widgets
```dart
// Anywhere in the widget tree:
final services = KcAppScope.of<MobileAppServices>(context);
final config = KcAppScope.configOf<MobileAppServices>(context);
```
## Backward compatibility (kell_web)
The `kell_web` app retains its original class names (`AppConfig`, `AppScope`,
`Bootstrap`, `AppServices`) as thin wrappers/typedefs around the shared `core`
types. Existing code continues to work without modification:
| Original (kell_web) | Shared (core) | Relationship |
| ------------------- | ------------------ | --------------- |
| `AppConfig` | `KcAppConfig` | typedef |
| `AppEnvironment` | `KcAppEnvironment` | typedef |
| `AppServices` | `KcAppServices` | extends |
| `AppScope` | `KcAppScope` | extends (typed) |
| `Bootstrap` | `KcBootstrap` | delegates |
New code in `kell_web` may use either the original names or the shared `Kc`-prefixed
names. The shared names are preferred for clarity and consistency with `kell_mobile`.
## Runtime configuration
All app targets use the same `--dart-define` keys:
| 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) |
When `KC_ENV=wordpress` but credentials are missing, `KcBootstrap` automatically
falls back to fake mode with a debug-mode warning.
## What remains app-specific
The following concerns are **not** shared and remain in each app target:
- **App shell / navigation** — Web uses `NavigationRail`, mobile will use
`BottomNavigationBar` or similar.
- **Routing** — Route definitions and page builders are platform-specific.
- **Platform-specific presentation** — Layout, responsive breakpoints, etc.
- **Cross-feature navigation handoffs** — Wired in the app's routing layer.
The `design_system` package provides shared visual components (widgets, theme,
typography, breakpoints) that both web and mobile can use for consistent styling.

View File

@ -1,12 +1,3 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at

View File

@ -1,23 +1,5 @@
/// Authentication and credential management for the Kell Creations platform.
///
/// This package provides:
///
/// - [KcUser] / [KcUserRole] — user identity domain models
/// - [AuthState] hierarchy — [AuthStateUnauthenticated], [AuthStateLoading],
/// [AuthStateAuthenticated], [AuthStateError]
/// - [AuthService] — abstract contract for sign-in, sign-out, and token refresh
/// - [AuthException] — thrown by [AuthService] on failure
/// - [CredentialStore] / [CredentialKeys] — secure key/value storage contract
/// for API keys and session tokens
/// - [InMemoryCredentialStore] — in-memory implementation for tests and fake mode
/// - [FakeAuthService] — in-memory [AuthService] for fake mode and tests
library;
// Domain
export 'src/domain/auth_service.dart';
export 'src/domain/auth_state.dart';
export 'src/domain/credential_store.dart';
export 'src/domain/user.dart';
// Data
export 'src/data/fake_auth_service.dart';
/// A Calculator.
class Calculator {
/// Returns [value] plus 1.
int addOne(int value) => value + 1;
}

View File

@ -1,109 +0,0 @@
import 'dart:async';
import '../domain/auth_service.dart';
import '../domain/auth_state.dart';
import '../domain/user.dart';
/// An in-memory [AuthService] implementation for use in `fake` mode and tests.
///
/// Accepts any non-empty email/password combination and returns a synthetic
/// [KcUser]. No network calls are made. State is held in memory and lost
/// when the process exits.
///
/// ## Fake credentials
///
/// | Email | Role |
/// |------------------------------|----------|
/// | `admin@kellcreations.com` | admin |
/// | `operator@kellcreations.com` | operator |
/// | any other non-empty email | viewer |
///
/// Any non-empty password is accepted. An empty password throws [AuthException].
class FakeAuthService implements AuthService {
final StreamController<AuthState> _controller = StreamController<AuthState>.broadcast();
AuthState _state = const AuthStateUnauthenticated();
@override
Stream<AuthState> get authStateStream async* {
// Emit current state immediately on subscription.
yield _state;
yield* _controller.stream;
}
@override
AuthState get currentState => _state;
@override
KcUser? get currentUser => _state.user;
@override
Future<KcUser> signIn({required String email, required String password}) async {
_emit(const AuthStateLoading());
// Simulate a short async delay.
await Future<void>.delayed(const Duration(milliseconds: 10));
if (email.isEmpty) {
final error = const AuthException(message: 'Email must not be empty.');
_emit(AuthStateError(message: error.message));
throw error;
}
if (password.isEmpty) {
final error = const AuthException(message: 'Password must not be empty.');
_emit(AuthStateError(message: error.message));
throw error;
}
final role = _roleForEmail(email);
final user = KcUser(
id: 'fake-${email.hashCode.abs()}',
displayName: _displayNameForEmail(email),
email: email,
role: role,
);
_emit(AuthStateAuthenticated(user: user));
return user;
}
@override
Future<void> signOut() async {
_emit(const AuthStateLoading());
await Future<void>.delayed(const Duration(milliseconds: 10));
_emit(const AuthStateUnauthenticated());
}
@override
Future<bool> refreshSession() async {
// In fake mode the session never expires.
return _state.isAuthenticated;
}
@override
void dispose() {
_controller.close();
}
void _emit(AuthState state) {
_state = state;
if (!_controller.isClosed) {
_controller.add(state);
}
}
KcUserRole _roleForEmail(String email) {
if (email == 'admin@kellcreations.com') return KcUserRole.admin;
if (email == 'operator@kellcreations.com') return KcUserRole.operator;
return KcUserRole.viewer;
}
String _displayNameForEmail(String email) {
if (email == 'admin@kellcreations.com') return 'Admin User';
if (email == 'operator@kellcreations.com') return 'Operator User';
// Derive a display name from the local part of the email.
final local = email.split('@').first;
return local.isNotEmpty ? local : email;
}
}

View File

@ -1,62 +0,0 @@
import 'auth_state.dart';
import 'user.dart';
/// Abstract contract for authentication operations.
///
/// Implementations handle login, logout, session persistence, and token
/// refresh. The concrete implementation is selected at runtime — a
/// [FakeAuthService] is used in `fake` mode; a real implementation (e.g.
/// local credential validation or an OAuth flow) is used in production.
///
/// The service exposes the current [AuthState] as a [Stream] so that widgets
/// and controllers can reactively rebuild when auth state changes.
abstract class AuthService {
/// A stream that emits the current [AuthState] whenever it changes.
///
/// The stream always emits the current state immediately upon subscription
/// (i.e. it behaves like a `BehaviorSubject`).
Stream<AuthState> get authStateStream;
/// The current [AuthState] synchronously.
AuthState get currentState;
/// The currently authenticated [KcUser], or `null` if not signed in.
KcUser? get currentUser;
/// Attempts to sign in with the given [email] and [password].
///
/// Returns the authenticated [KcUser] on success.
/// Throws [AuthException] on failure (wrong credentials, network error, etc.).
Future<KcUser> signIn({required String email, required String password});
/// Signs out the current user and clears any persisted session.
Future<void> signOut();
/// Attempts to refresh the current session token.
///
/// Returns `true` if the session was successfully refreshed.
/// Returns `false` if the session has expired and the user must sign in again.
Future<bool> refreshSession();
/// Releases resources held by this service (stream controllers, timers).
void dispose();
}
/// Thrown when an authentication operation fails.
class AuthException implements Exception {
/// A human-readable description of the failure.
final String message;
/// The HTTP status code, if the failure originated from an API call.
final int? statusCode;
/// The underlying cause, if available.
final Object? cause;
const AuthException({required this.message, this.statusCode, this.cause});
@override
String toString() =>
'AuthException: $message'
'${statusCode != null ? ' (HTTP $statusCode)' : ''}';
}

View File

@ -1,85 +0,0 @@
import 'user.dart';
/// Represents the current authentication state of the application.
///
/// [AuthState] is a sealed-style class hierarchy using a discriminated union
/// pattern. The app always holds exactly one of:
///
/// - [AuthStateUnauthenticated] — no user is signed in
/// - [AuthStateLoading] — an auth operation is in progress
/// - [AuthStateAuthenticated] — a user is signed in
/// - [AuthStateError] — an auth operation failed
///
/// Use [AuthState.isAuthenticated], [AuthState.user], and the `when`-style
/// helpers to branch on state without casting.
sealed class AuthState {
const AuthState();
/// Returns `true` if the user is currently authenticated.
bool get isAuthenticated => this is AuthStateAuthenticated;
/// Returns `true` if an auth operation is in progress.
bool get isLoading => this is AuthStateLoading;
/// Returns the authenticated [KcUser], or `null` if not authenticated.
KcUser? get user => switch (this) {
AuthStateAuthenticated(:final user) => user,
_ => null,
};
}
/// No user is currently signed in.
final class AuthStateUnauthenticated extends AuthState {
const AuthStateUnauthenticated();
@override
String toString() => 'AuthState.unauthenticated';
}
/// An authentication operation (login, logout, token refresh) is in progress.
final class AuthStateLoading extends AuthState {
const AuthStateLoading();
@override
String toString() => 'AuthState.loading';
}
/// A user is signed in and the session is valid.
final class AuthStateAuthenticated extends AuthState {
/// The currently authenticated user.
@override
final KcUser user;
const AuthStateAuthenticated({required this.user});
@override
bool operator ==(Object other) =>
identical(this, other) || other is AuthStateAuthenticated && user == other.user;
@override
int get hashCode => user.hashCode;
@override
String toString() => 'AuthState.authenticated(user: $user)';
}
/// An authentication operation failed.
final class AuthStateError extends AuthState {
/// A human-readable description of what went wrong.
final String message;
/// The underlying exception, if available.
final Object? cause;
const AuthStateError({required this.message, this.cause});
@override
bool operator ==(Object other) =>
identical(this, other) || other is AuthStateError && message == other.message;
@override
int get hashCode => message.hashCode;
@override
String toString() => 'AuthState.error(message: $message)';
}

View File

@ -1,84 +0,0 @@
/// Abstract contract for secure storage of API credentials.
///
/// [CredentialStore] provides a key/value interface for storing sensitive
/// values (API keys, tokens, secrets) in a secure location appropriate for
/// the platform. On Android/iOS this maps to the OS keystore; on desktop/web
/// it maps to an encrypted local store.
///
/// Concrete implementations:
/// - [InMemoryCredentialStore] — in-memory, non-persistent (for tests and fake mode)
/// - `SecureCredentialStore` — backed by `flutter_secure_storage` (production)
///
/// ## Well-known credential keys
///
/// Use the constants on [CredentialKeys] rather than raw strings to avoid
/// typos and ensure consistency across the codebase.
abstract class CredentialStore {
/// Reads the credential stored under [key].
///
/// Returns `null` if no value has been stored for [key].
Future<String?> read(String key);
/// Writes [value] under [key], replacing any existing value.
Future<void> write(String key, String value);
/// Deletes the credential stored under [key].
///
/// No-op if [key] is not present.
Future<void> delete(String key);
/// Deletes all stored credentials.
///
/// Use with caution — this clears all API keys and tokens.
Future<void> deleteAll();
/// Returns `true` if a non-null value is stored under [key].
Future<bool> containsKey(String key);
}
/// Well-known keys for credentials stored in [CredentialStore].
///
/// Using these constants prevents typos and ensures all parts of the
/// codebase refer to the same storage keys.
abstract final class CredentialKeys {
/// WooCommerce REST API consumer key (`ck_...`).
static const String wcConsumerKey = 'kc.wc.consumer_key';
/// WooCommerce REST API consumer secret (`cs_...`).
static const String wcConsumerSecret = 'kc.wc.consumer_secret';
/// WooCommerce site URL.
static const String wcSiteUrl = 'kc.wc.site_url';
/// Square API access token.
static const String squareAccessToken = 'kc.square.access_token';
/// Square location ID.
static const String squareLocationId = 'kc.square.location_id';
/// The currently authenticated user's session token (if applicable).
static const String sessionToken = 'kc.auth.session_token';
}
/// An in-memory [CredentialStore] backed by a plain [Map].
///
/// Values are lost when the process exits. Suitable for tests and fake mode
/// where no real credentials need to be persisted.
class InMemoryCredentialStore implements CredentialStore {
final Map<String, String> _store = {};
@override
Future<String?> read(String key) async => _store[key];
@override
Future<void> write(String key, String value) async => _store[key] = value;
@override
Future<void> delete(String key) async => _store.remove(key);
@override
Future<void> deleteAll() async => _store.clear();
@override
Future<bool> containsKey(String key) async => _store.containsKey(key);
}

View File

@ -1,63 +0,0 @@
/// Represents an authenticated user of the Kell Creations platform.
///
/// This is a lightweight identity model — it carries the user's display
/// information and role but does not hold credentials or tokens directly.
/// Credentials are managed separately by [CredentialStore].
class KcUser {
/// Unique identifier for this user (e.g. a UUID or username).
final String id;
/// Human-readable display name.
final String displayName;
/// Email address (used as the primary login identifier).
final String email;
/// The role assigned to this user, controlling what actions they may perform.
final KcUserRole role;
const KcUser({
required this.id,
required this.displayName,
required this.email,
required this.role,
});
/// Returns a copy of this user with the given fields replaced.
KcUser copyWith({String? id, String? displayName, String? email, KcUserRole? role}) {
return KcUser(
id: id ?? this.id,
displayName: displayName ?? this.displayName,
email: email ?? this.email,
role: role ?? this.role,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is KcUser &&
id == other.id &&
displayName == other.displayName &&
email == other.email &&
role == other.role;
@override
int get hashCode => Object.hash(id, displayName, email, role);
@override
String toString() =>
'KcUser(id: $id, displayName: $displayName, email: $email, role: ${role.name})';
}
/// The role assigned to a [KcUser], controlling access to platform features.
enum KcUserRole {
/// Full access to all platform features including configuration.
admin,
/// Standard operator access — can manage products, orders, and inventory.
operator,
/// Read-only access — can view but not modify data.
viewer,
}

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage:
environment:
sdk: ^3.11.0
sdk: ^3.11.4
flutter: ">=1.17.0"
dependencies:

View File

@ -3,350 +3,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:auth/auth.dart';
void main() {
// ---------------------------------------------------------------------------
// KcUser
// ---------------------------------------------------------------------------
group('KcUser', () {
const user = KcUser(
id: 'u1',
displayName: 'Alice',
email: 'alice@example.com',
role: KcUserRole.operator,
);
test('stores all fields', () {
expect(user.id, 'u1');
expect(user.displayName, 'Alice');
expect(user.email, 'alice@example.com');
expect(user.role, KcUserRole.operator);
});
test('copyWith replaces specified fields', () {
final updated = user.copyWith(displayName: 'Bob', role: KcUserRole.admin);
expect(updated.id, 'u1');
expect(updated.displayName, 'Bob');
expect(updated.email, 'alice@example.com');
expect(updated.role, KcUserRole.admin);
});
test('copyWith with no args returns equivalent user', () {
final copy = user.copyWith();
expect(copy, equals(user));
});
test('equality compares all fields', () {
const same = KcUser(
id: 'u1',
displayName: 'Alice',
email: 'alice@example.com',
role: KcUserRole.operator,
);
const different = KcUser(
id: 'u2',
displayName: 'Alice',
email: 'alice@example.com',
role: KcUserRole.operator,
);
expect(user, equals(same));
expect(user, isNot(equals(different)));
expect(user.hashCode, equals(same.hashCode));
});
test('toString includes key fields', () {
expect(user.toString(), contains('u1'));
expect(user.toString(), contains('Alice'));
expect(user.toString(), contains('operator'));
});
});
// ---------------------------------------------------------------------------
// KcUserRole
// ---------------------------------------------------------------------------
group('KcUserRole', () {
test('has three values', () {
expect(KcUserRole.values.length, 3);
});
test('values are admin, operator, viewer', () {
expect(
KcUserRole.values,
containsAll([KcUserRole.admin, KcUserRole.operator, KcUserRole.viewer]),
);
});
});
// ---------------------------------------------------------------------------
// AuthState
// ---------------------------------------------------------------------------
group('AuthState', () {
const user = KcUser(
id: 'u1',
displayName: 'Alice',
email: 'alice@example.com',
role: KcUserRole.admin,
);
test('unauthenticated: isAuthenticated is false', () {
const state = AuthStateUnauthenticated();
expect(state.isAuthenticated, isFalse);
expect(state.isLoading, isFalse);
expect(state.user, isNull);
});
test('loading: isLoading is true', () {
const state = AuthStateLoading();
expect(state.isLoading, isTrue);
expect(state.isAuthenticated, isFalse);
expect(state.user, isNull);
});
test('authenticated: isAuthenticated is true and user is set', () {
final state = AuthStateAuthenticated(user: user);
expect(state.isAuthenticated, isTrue);
expect(state.isLoading, isFalse);
expect(state.user, equals(user));
});
test('authenticated: equality compares user', () {
final a = AuthStateAuthenticated(user: user);
final b = AuthStateAuthenticated(user: user);
const c = KcUser(
id: 'u2',
displayName: 'Bob',
email: 'bob@example.com',
role: KcUserRole.viewer,
);
final d = AuthStateAuthenticated(user: c);
expect(a, equals(b));
expect(a, isNot(equals(d)));
expect(a.hashCode, equals(b.hashCode));
});
test('error: message is stored', () {
const state = AuthStateError(message: 'Invalid credentials');
expect(state.isAuthenticated, isFalse);
expect(state.user, isNull);
expect(state.message, 'Invalid credentials');
});
test('error: equality compares message', () {
const a = AuthStateError(message: 'oops');
const b = AuthStateError(message: 'oops');
const c = AuthStateError(message: 'different');
expect(a, equals(b));
expect(a, isNot(equals(c)));
});
test('toString includes state type', () {
expect(const AuthStateUnauthenticated().toString(), contains('unauthenticated'));
expect(const AuthStateLoading().toString(), contains('loading'));
expect(AuthStateAuthenticated(user: user).toString(), contains('authenticated'));
expect(const AuthStateError(message: 'x').toString(), contains('error'));
});
});
// ---------------------------------------------------------------------------
// AuthException
// ---------------------------------------------------------------------------
group('AuthException', () {
test('stores message and optional statusCode', () {
const ex = AuthException(message: 'Unauthorized', statusCode: 401);
expect(ex.message, 'Unauthorized');
expect(ex.statusCode, 401);
});
test('toString includes message', () {
const ex = AuthException(message: 'Forbidden');
expect(ex.toString(), contains('Forbidden'));
});
test('toString includes HTTP status when present', () {
const ex = AuthException(message: 'Unauthorized', statusCode: 401);
expect(ex.toString(), contains('401'));
});
test('statusCode is optional', () {
const ex = AuthException(message: 'Network error');
expect(ex.statusCode, isNull);
});
});
// ---------------------------------------------------------------------------
// CredentialStore / CredentialKeys
// ---------------------------------------------------------------------------
group('InMemoryCredentialStore', () {
late InMemoryCredentialStore store;
setUp(() => store = InMemoryCredentialStore());
test('read returns null for missing key', () async {
expect(await store.read('missing'), isNull);
});
test('write and read round-trip', () async {
await store.write(CredentialKeys.wcConsumerKey, 'ck_test');
expect(await store.read(CredentialKeys.wcConsumerKey), 'ck_test');
});
test('write overwrites existing value', () async {
await store.write('key', 'first');
await store.write('key', 'second');
expect(await store.read('key'), 'second');
});
test('delete removes key', () async {
await store.write('key', 'value');
await store.delete('key');
expect(await store.read('key'), isNull);
});
test('delete is no-op for missing key', () async {
await expectLater(store.delete('missing'), completes);
});
test('deleteAll clears all keys', () async {
await store.write('a', '1');
await store.write('b', '2');
await store.deleteAll();
expect(await store.read('a'), isNull);
expect(await store.read('b'), isNull);
});
test('containsKey returns true for present key', () async {
await store.write('key', 'value');
expect(await store.containsKey('key'), isTrue);
});
test('containsKey returns false for missing key', () async {
expect(await store.containsKey('missing'), isFalse);
});
});
group('CredentialKeys', () {
test('all keys are non-empty strings', () {
expect(CredentialKeys.wcConsumerKey, isNotEmpty);
expect(CredentialKeys.wcConsumerSecret, isNotEmpty);
expect(CredentialKeys.wcSiteUrl, isNotEmpty);
expect(CredentialKeys.squareAccessToken, isNotEmpty);
expect(CredentialKeys.squareLocationId, isNotEmpty);
expect(CredentialKeys.sessionToken, isNotEmpty);
});
test('all keys are unique', () {
final keys = [
CredentialKeys.wcConsumerKey,
CredentialKeys.wcConsumerSecret,
CredentialKeys.wcSiteUrl,
CredentialKeys.squareAccessToken,
CredentialKeys.squareLocationId,
CredentialKeys.sessionToken,
];
expect(keys.toSet().length, keys.length);
});
});
// ---------------------------------------------------------------------------
// FakeAuthService
// ---------------------------------------------------------------------------
group('FakeAuthService', () {
late FakeAuthService service;
setUp(() => service = FakeAuthService());
tearDown(() => service.dispose());
test('initial state is unauthenticated', () {
expect(service.currentState, isA<AuthStateUnauthenticated>());
expect(service.currentUser, isNull);
});
test('authStateStream emits current state immediately', () async {
final first = await service.authStateStream.first;
expect(first, isA<AuthStateUnauthenticated>());
});
test('signIn with valid credentials returns user', () async {
final user = await service.signIn(email: 'operator@kellcreations.com', password: 'secret');
expect(user.email, 'operator@kellcreations.com');
expect(user.role, KcUserRole.operator);
});
test('signIn sets state to authenticated', () async {
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
expect(service.currentState, isA<AuthStateAuthenticated>());
expect(service.currentUser?.role, KcUserRole.admin);
});
test('signIn with admin email assigns admin role', () async {
final user = await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
expect(user.role, KcUserRole.admin);
});
test('signIn with operator email assigns operator role', () async {
final user = await service.signIn(email: 'operator@kellcreations.com', password: 'pw');
expect(user.role, KcUserRole.operator);
});
test('signIn with unknown email assigns viewer role', () async {
final user = await service.signIn(email: 'someone@example.com', password: 'pw');
expect(user.role, KcUserRole.viewer);
});
test('signIn with empty email throws AuthException', () async {
await expectLater(service.signIn(email: '', password: 'pw'), throwsA(isA<AuthException>()));
expect(service.currentState, isA<AuthStateError>());
});
test('signIn with empty password throws AuthException', () async {
await expectLater(
service.signIn(email: 'user@example.com', password: ''),
throwsA(isA<AuthException>()),
);
expect(service.currentState, isA<AuthStateError>());
});
test('signOut sets state to unauthenticated', () async {
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
await service.signOut();
expect(service.currentState, isA<AuthStateUnauthenticated>());
expect(service.currentUser, isNull);
});
test('refreshSession returns true when authenticated', () async {
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
expect(await service.refreshSession(), isTrue);
});
test('refreshSession returns false when unauthenticated', () async {
expect(await service.refreshSession(), isFalse);
});
test('authStateStream emits loading then authenticated on signIn', () async {
final states = <AuthState>[];
final sub = service.authStateStream.listen(states.add);
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
// Allow any pending microtasks to flush.
await Future<void>.delayed(Duration.zero);
await sub.cancel();
// Stream: unauthenticated (initial), loading, authenticated
expect(states.length, greaterThanOrEqualTo(2));
expect(states.any((s) => s is AuthStateLoading), isTrue);
expect(states.last, isA<AuthStateAuthenticated>());
});
test('authStateStream emits loading then unauthenticated on signOut', () async {
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
final states = <AuthState>[];
final sub = service.authStateStream.listen(states.add);
await service.signOut();
// Allow any pending microtasks to flush.
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(states.last, isA<AuthStateUnauthenticated>());
});
test('adds one to input values', () {
final calculator = Calculator();
expect(calculator.addOne(2), 3);
expect(calculator.addOne(-7), -6);
expect(calculator.addOne(0), 1);
});
}

View File

@ -1,12 +1,3 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at

View File

@ -1,37 +1,5 @@
/// Core shared abstractions for Kell Creations applications.
///
/// This package provides the platform-agnostic composition pattern used by
/// all app targets (`kell_web`, `kell_mobile`, etc.):
///
/// - [KcAppConfig] / [KcAppEnvironment] — runtime configuration from `--dart-define`
/// - [KcAppServices] — abstract service container base class
/// - [KcServiceFactory] — generic factory for environment-aware service construction
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
///
/// It also provides the shared observability stack used by every app target:
///
/// - [KcLogger] / [KcLogRecord] / [KcLogLevel] — structured logging
/// - [KcLogSink] implementations — console, bounded in-memory buffer, fan-out
/// - [KcErrorReporter] — global uncaught-error capture
/// - [KcBugReportService] / [KcBugReport] — operator bug reporting with
/// diagnostic evidence attached
/// - [KcRedactor] — credential redaction applied to all logged context
library;
// Composition
export 'src/composition/kc_app_config.dart';
export 'src/composition/kc_app_scope.dart';
export 'src/composition/kc_app_services.dart';
export 'src/composition/kc_bootstrap.dart';
// Observability
export 'src/observability/kc_bug_report.dart';
export 'src/observability/kc_bug_report_service.dart';
export 'src/observability/kc_error_reporter.dart';
export 'src/observability/kc_log_level.dart';
export 'src/observability/kc_log_record.dart';
export 'src/observability/kc_log_sink.dart';
export 'src/observability/kc_logger.dart';
export 'src/observability/kc_observability.dart';
export 'src/observability/kc_redactor.dart';
/// A Calculator.
class Calculator {
/// Returns [value] plus 1.
int addOne(int value) => value + 1;
}

View File

@ -1,175 +0,0 @@
/// Runtime configuration read from `--dart-define` values.
///
/// This is the shared, platform-agnostic configuration model used by all
/// Kell Creations app targets (`kell_web`, `kell_mobile`, etc.).
///
/// The app reads the following compile-time constants:
///
/// | Key | Description | Default |
/// |--------------------------|----------------------------------------------|------------|
/// | `KC_ENV` | `fake`, `wordpress`, `square`, or `multi` | `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) |
/// | `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) |
/// | `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) |
/// | `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` |
///
/// 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
/// ```
///
/// Square mode:
/// ```sh
/// flutter run -d android \
/// --dart-define=KC_ENV=square \
/// --dart-define=KC_SQUARE_ACCESS_TOKEN=EAAAl... \
/// --dart-define=KC_SQUARE_LOCATION_ID=L... \
/// --dart-define=KC_SQUARE_ENVIRONMENT=sandbox
/// ```
class KcAppConfig {
/// The environment mode: `fake`, `wordpress`, `square`, or `multi`.
final KcAppEnvironment environment;
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]
/// and [KcAppEnvironment.multi]).
final String wcSiteUrl;
/// WooCommerce REST API consumer key.
final String wcConsumerKey;
/// WooCommerce REST API consumer secret.
final String wcConsumerSecret;
/// Square API access token (Bearer token).
///
/// Used in [KcAppEnvironment.square] and [KcAppEnvironment.multi].
final String squareAccessToken;
/// Square location ID for inventory counts and payment processing.
///
/// Used in [KcAppEnvironment.square] and [KcAppEnvironment.multi].
final String squareLocationId;
/// Square API environment: `sandbox` or `production`.
///
/// Defaults to `sandbox` for safety.
final String squareEnvironment;
const KcAppConfig({
required this.environment,
required this.wcSiteUrl,
required this.wcConsumerKey,
required this.wcConsumerSecret,
this.squareAccessToken = '',
this.squareLocationId = '',
this.squareEnvironment = 'sandbox',
});
/// Reads configuration from compile-time `--dart-define` constants.
factory KcAppConfig.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');
const squareAccessToken = String.fromEnvironment('KC_SQUARE_ACCESS_TOKEN');
const squareLocationId = String.fromEnvironment('KC_SQUARE_LOCATION_ID');
const squareEnvironment = String.fromEnvironment(
'KC_SQUARE_ENVIRONMENT',
defaultValue: 'sandbox',
);
final environment = KcAppEnvironment.fromString(envString);
return KcAppConfig(
environment: environment,
wcSiteUrl: siteUrl,
wcConsumerKey: consumerKey,
wcConsumerSecret: consumerSecret,
squareAccessToken: squareAccessToken,
squareLocationId: squareLocationId,
squareEnvironment: squareEnvironment,
);
}
/// Whether the WordPress configuration values are all present and non-empty.
bool get hasWordPressConfig =>
wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty;
/// Whether the Square configuration values are all present and non-empty.
bool get hasSquareConfig => squareAccessToken.isNotEmpty && squareLocationId.isNotEmpty;
/// Whether the Square environment is production (vs. sandbox).
bool get isSquareProduction => squareEnvironment == 'production';
/// A human-readable label for the current environment (e.g. for badges).
String get environmentLabel => environment.label;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is KcAppConfig &&
environment == other.environment &&
wcSiteUrl == other.wcSiteUrl &&
wcConsumerKey == other.wcConsumerKey &&
wcConsumerSecret == other.wcConsumerSecret &&
squareAccessToken == other.squareAccessToken &&
squareLocationId == other.squareLocationId &&
squareEnvironment == other.squareEnvironment;
@override
int get hashCode => Object.hash(
environment,
wcSiteUrl,
wcConsumerKey,
wcConsumerSecret,
squareAccessToken,
squareLocationId,
squareEnvironment,
);
@override
String toString() =>
'KcAppConfig(environment: ${environment.key}, '
'hasWordPressConfig: $hasWordPressConfig, '
'hasSquareConfig: $hasSquareConfig)';
}
/// The supported runtime environments.
enum KcAppEnvironment {
/// In-memory fakes – no network calls.
fake('fake', 'FAKE'),
/// Real WooCommerce backend.
wordpress('wordpress', 'WP'),
/// Real Square backend (catalog, inventory, payments).
///
/// Activated in Stage 10A. Selecting this mode wires the Square API client
/// for catalog, inventory, and payment operations.
square('square', 'SQ'),
/// Both WooCommerce and Square backends active simultaneously.
///
/// Planned for Stage 13A (multi-channel sync). Selecting this mode wires
/// both the WooCommerce and Square API clients.
multi('multi', 'MULTI');
final String key;
final String label;
const KcAppEnvironment(this.key, this.label);
/// Parses a string into a [KcAppEnvironment], defaulting to [fake].
static KcAppEnvironment fromString(String value) {
for (final env in values) {
if (env.key == value.toLowerCase()) return env;
}
return fake;
}
}

Some files were not shown because too many files have changed in this diff Show More