Compare commits
No commits in common. "main" and "feat/publishing-ux-hardening" have entirely different histories.
main
...
feat/publi
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -4,6 +4,3 @@ __pycache__/
|
|||
.venv/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Flutter test coverage output
|
||||
coverage/
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"MD013": false,
|
||||
"MD024": {
|
||||
"siblings_only": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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._
|
||||
|
|
@ -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
|
||||
|
||||

|
||||
|
|
@ -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.
|
||||
|
|
@ -14,7 +14,7 @@ The source for this diagram is maintained as architecture code in:
|
|||
|
||||
## Diagram
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
|
@ -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
|
||||
|
||||

|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||

|
||||
```
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -2,325 +2,13 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
|
||||
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
|
||||
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
||||
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
|
||||
- merged: `feat/square-mobile-pos` (Stage 10C POS) — merged to main, commit `7bf8f55`
|
||||
- merged: `feat/observability-error-logging` (Stage 16A) — error logging, crash capture, and bug reporting; merged to main 2026-08-29
|
||||
- merged: `feat/custom-order-domain` (Stage 11A) — custom order domain and data; merged to main 2026-08-29, commit `85501fd`
|
||||
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29)
|
||||
- reconciled: `chore/state-reconciliation-flutter-347` — working tree vs. `main` reconciled 2026-09-25; toolchain moved to Flutter 3.47.0 / Dart 3.13.0; master documents re-baselined
|
||||
- merged: `feat/custom-order-ui` (Stage 11B) — custom order intake UI; merged to main 2026-09-25, commit `6c299e8`
|
||||
- main baseline updated through: `feat/custom-order-ui` (Stage 11B complete, merged 2026-09-25)
|
||||
- merged: `feat/custom-order-tracking` (Stage 11C) — custom order tracking and fulfillment; merged to main 2026-09-25, commit `07e7e08`. **Stage 11 complete; audit finding P2.2 closed**
|
||||
- main baseline updated through: `feat/custom-order-tracking` (Stage 11C complete, merged 2026-09-25)
|
||||
- next branch: `feat/inventory-write-ops` (Stage 12A) — Inventory write operations and domain expansion
|
||||
- total tests on branch: 2373/2373 passing (+329 new: 293 in feature_orders, 22 in kell_mobile, 14 in kell_web); dart analyze --fatal-infos clean across all 11 packages and apps
|
||||
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
|
||||
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
|
||||
- toolchain: Flutter 3.47.0 / Dart 3.13.0. Flutter 3.47 auto-generates an `analyzer.exclude` block in every `analysis_options.yaml` on each `pub get` / `analyze` / `run` / `build` with no opt-out ([flutter#191160](https://github.com/flutter/flutter/issues/191160)). **Do not open a slice to revert it** — see "Toolchain baseline" in `master_development_brief.md`
|
||||
- main baseline updated through: post-write-consistency
|
||||
- main baseline commit: `7acff83` (2026-04-11)
|
||||
- next branch: feat/publishing-ux-hardening
|
||||
- current stage: Stage 2 — Web application operational hardening
|
||||
|
||||
## Slice tracker
|
||||
|
||||
### feat/custom-order-tracking
|
||||
|
||||
- status: merged to main (commit `07e7e08`)
|
||||
- branch: `feat/custom-order-tracking`
|
||||
- stage: 11C — Custom order tracking and fulfillment
|
||||
- date: 2026-09-25
|
||||
- baseline: `6c299e8` (main, Stage 11B complete)
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- closes audit findings: P2.2 (tracking portion — closes the finding entirely; Stage 11 is now complete)
|
||||
- scope: domain, data, application, presentation, and app wiring across `feature_orders`, `kell_web`, and `kell_mobile`
|
||||
- files added — `feature_orders` domain:
|
||||
- `lib/src/domain/fulfillment_event.dart` — `FulfillmentEvent` (from/status/occurredAt/note) with `intake()`, `isIntake`, `isRework`, `hasNote`, `description`, and a `from|to|iso|note` wire format that sanitises separators out of notes; `fromWire()` tolerates malformed lines
|
||||
- `lib/src/domain/material_usage.dart` — `MaterialUsage` (sku, quantity, displayName) with `isValid`, `label`, and a `sku|qty|name` wire format
|
||||
- `lib/src/domain/material_consumption_port.dart` — `MaterialDeductionResult` (per-SKU `failures`, `isPartial`, `totalUnitsDeducted`, `summary`) and the `MaterialConsumptionPort` outbound port, plus `NoopMaterialConsumptionPort`
|
||||
- files added — `feature_orders` application:
|
||||
- `lib/src/application/advance_fulfillment.dart` — `AdvanceFulfillment` use case; adds no rule of its own, so the pipeline is defined in exactly one place
|
||||
- `lib/src/application/complete_custom_order.dart` — `CompleteCustomOrder` + `CompleteCustomOrderResult`; advances **first**, deducts second, and never lets a stock failure fail the completion
|
||||
- `lib/src/application/custom_order_tracking_controller.dart` — `CustomOrderTrackingController` (`ChangeNotifier`): custom-order-only load, stage filter, search over customer/ID/spec summary, `stageCounts`, `activeCount`, `advanceTo()` (routing completions through `CompleteCustomOrder`), and `consumeActionResult()` / `consumeDeductionWarning()`
|
||||
- files added — `feature_orders` presentation:
|
||||
- `lib/src/presentation/custom_order_tracking_page.dart` — `CustomOrderTrackingPage`; master-detail above `KcBreakpoints.medium`, pushed detail below it
|
||||
- `lib/src/presentation/widgets/fulfillment_timeline.dart` — `FulfillmentTimeline`; dot-and-rail rows, rework marker, colour-coded by outcome
|
||||
- `lib/src/presentation/widgets/custom_order_tracking_card.dart` — `CustomOrderTrackingCard`; leads with the **workshop stage**, spec summary, balance, and last update
|
||||
- `lib/src/presentation/widgets/custom_order_tracking_detail.dart` — `CustomOrderTrackingDetail`; stage action, spec, deposit/balance, materials, timeline
|
||||
- `lib/src/presentation/widgets/advance_stage_dialog.dart` — `showAdvanceStageDialog()` + `AdvanceStageResult`; offers only domain-legal stages and an operator progress note
|
||||
- files changed — `feature_orders`:
|
||||
- `lib/src/domain/order.dart` — added `fulfillmentHistory` and `materialUsage` (both default `const []`); added `currentStage`, `hasFulfillmentHistory`, `latestFulfillmentEvent`, `lastFulfillmentUpdate`, `allowedNextStages`, `isFulfillmentComplete`, `hasMaterialUsage`, `deductibleMaterials`, and the append-only `withFulfillmentEvent()`. **All existing fields, defaults, and `copyWith` semantics preserved** — every Stage 11A/11B test passed unchanged (792/792 verified before any new test was added)
|
||||
- `lib/src/domain/orders_repository.dart` — `advanceFulfillmentStatus()` gained optional `note` and `occurredAt`; added `recordMaterialUsage()`
|
||||
- `lib/src/data/fake_orders_repository.dart` — injectable `now` clock; `advanceFulfillmentStatus` appends an event; `createCustomOrder` seeds an intake event; `recordMaterialUsage` implemented; all five seeded commissions now carry realistic histories (KC-C2004 includes a rework loop) and material usage
|
||||
- `lib/src/data/woo_commerce_orders_repository.dart` — injectable `now` clock; same history and material behaviour as the fake
|
||||
- `lib/src/data/woo_commerce_order_mapper.dart` — two new meta keys (`_kc_fulfillment_history`, `_kc_material_usage`), `trackingMetaEntries()`, `historyFromMeta()`, `materialUsageFromMeta()`; malformed lines are skipped, empty lists are omitted rather than blanking remote values
|
||||
- `lib/src/presentation/orders_page.dart` — optional `onTrackCustomOrders` callback adds a "Track Custom Orders" action beside the existing intake action; both omitted when the host has no route
|
||||
- `lib/feature_orders.dart` — barrel exports for all new types
|
||||
- `README.md` — documents the tracking workspace, the timeline, and the inventory-deduction port
|
||||
- files changed — apps:
|
||||
- `kell_web/lib/composition/inventory_material_consumption_adapter.dart` — **new** adapter bridging `InventoryRepository` to `MaterialConsumptionPort`; tolerant by contract, never throws, logs per-SKU failures
|
||||
- `kell_web/lib/composition/app_services.dart` — `materialConsumptionPort` built from the inventory repository
|
||||
- `kell_web/lib/routing/app_routes.dart` — new `/orders/tracking` route; `_parseStage()` returns `null` for an unknown stage rather than `FulfillmentStatus.fromWire`'s `quoted` default, which would be the wrong behaviour for a filter
|
||||
- `kell_web/lib/navigation/app_navigation.dart` — `ordersToCustomOrderTracking()`
|
||||
- `kell_mobile/lib/composition/inventory_material_consumption_adapter.dart` — **new**; deliberately duplicated rather than shared, matching the existing `get_dashboard_summary.dart` convention
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — `materialConsumptionPort`
|
||||
- `kell_mobile/lib/pages/mobile_custom_order_tracking_page.dart` — **new** full-screen `SafeArea` host for the shared page
|
||||
- `kell_mobile/lib/pages/mobile_orders_page.dart` — small tracking FAB above the existing intake FAB; reloads the list on return
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — passes the material port through
|
||||
- tests updated (contract widening):
|
||||
- `feature_orders/test/widgets/orders_page_test.dart` — `_EmptyOrdersRepository` satisfies the widened contract
|
||||
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — `_StubOrdersRepository` satisfies the widened contract
|
||||
- tests added (329 new):
|
||||
- `feature_orders/test/fulfillment_event_test.dart` — 32 tests (construction, intake, rework detection, description, wire round trip, separator/newline sanitisation, malformed-line tolerance, equality)
|
||||
- `feature_orders/test/material_usage_test.dart` + `material_consumption_port_test.dart` — 41 tests (validity, labels, wire format, deduction-result shape, summaries, unmodifiable collections, noop port, partial failure)
|
||||
- `feature_orders/test/order_tracking_test.dart` — 33 tests (defaults, `currentStage`, history accessors, allowed stages, deductible materials, `withFulfillmentEvent` immutability and append-only behaviour, `copyWith`)
|
||||
- `feature_orders/test/fake_orders_repository_tracking_test.dart` — 31 tests (seeded-history integrity, incl. checks that **every** seeded transition is domain-legal and that each history ends at the stamped stage; note trimming, clock injection, illegal-advance no-op, `recordMaterialUsage` validation)
|
||||
- `feature_orders/test/woo_commerce_tracking_meta_test.dart` — 22 tests (meta keys, packing, read/write round trip, malformed-line skipping, no payment identifier on the wire)
|
||||
- `feature_orders/test/complete_custom_order_test.dart` — 22 tests (ordering — no deduction on a rejected transition; partial-failure warnings, unwired port, empty materials, `AdvanceFulfillment` delegation and verbatim rejection reasons)
|
||||
- `feature_orders/test/custom_order_tracking_controller_test.dart` — 45 tests (load, stage filter, counts, search, selection, advance, rework, cancel, completion routing, deduction warnings, unwired use cases)
|
||||
- `feature_orders/test/widgets/fulfillment_timeline_test.dart` — 14 tests (empty state, rows, timestamps, notes, rework marker, ordering, `newestFirst`, privacy)
|
||||
- `feature_orders/test/widgets/custom_order_tracking_page_test.dart`, `custom_order_tracking_detail_test.dart`, `advance_stage_dialog_test.dart` — 53 tests (listing, stage chips with counts, search, intake action, header, stage-action states, spec, money, materials, timeline, dialog selection/notes/cancel)
|
||||
- `kell_mobile/test/pages/mobile_custom_order_tracking_page_test.dart` — 9 tests (app bar, shared host, `SafeArea`, listing, chips, filtering, intake action, port injection)
|
||||
- `kell_mobile/test/composition/inventory_material_consumption_adapter_test.dart` — 13 tests (real stock decrement, missing SKU, invalid record, rejected adjustment, throwing repository, plus **end-to-end** completion decrementing real inventory and a rejected completion leaving it untouched)
|
||||
- `kell_web/test/routing/custom_order_tracking_route_test.dart` — 14 tests (route constant, shell rendering, listing, chips, timeline, stage argument, unknown stage ignored, `selectedId`, orders entry point, port identity)
|
||||
- architecture: `feature_orders` still has **no dependency on `feature_inventory`**. The orders layer declares `MaterialConsumptionPort`; each app's composition layer supplies the adapter — the same "feature states what it needs, app decides how" pattern already used for cross-feature navigation callbacks
|
||||
- ordering guarantee: `CompleteCustomOrder` advances the order **before** deducting, so an illegal jump to `completed` can never decrement stock. Asserted by test in both the use case suite and the end-to-end adapter suite
|
||||
- privacy: operator progress notes are workshop-only and the dialog labels them as such; the timeline and tracking detail render **no payment identifier anywhere** — asserted by test. Log context is limited to order IDs, SKUs, stage names, and quantities, and still passes through `KcRedactor`
|
||||
- tests: passed (1085/1085 feature_orders +293, 109/109 kell_mobile +22, 81/81 kell_web +14; all other packages unchanged — core 90, design_system 51, auth 42, integrations 123, data 63, feature_wordpress 545, feature_inventory 167, feature_policy 17)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found in `feature_orders`, `kell_web`, or `kell_mobile`)
|
||||
- format: `dart format --line-length 100` applied to every file touched by this slice
|
||||
- test-harness notes: the stage chip row scrolls horizontally, so tests `scrollUntilVisible` before tapping a later chip rather than working around a layout defect; the detail panel's indeterminate `LinearProgressIndicator` never settles, so the `isUpdating` test uses `pump()` rather than `pumpAndSettle()`
|
||||
- defect found and fixed while testing: `CustomOrderTrackingCard`'s footer row overflowed at the 340 px master-list width; both halves are now `Flexible` with ellipsis
|
||||
- brief updated: yes
|
||||
|
||||
### feat/custom-order-ui
|
||||
|
||||
- status: merged to main
|
||||
- branch: `feat/custom-order-ui`
|
||||
- stage: 11B — Custom order creation UI
|
||||
- date: 2026-09-25
|
||||
- baseline: `0e3987b` (main, Stage 11A complete + state reconciliation)
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- closes audit findings: P2.2 (UI portion — intake and confirmation; tracking remains open as Stage 11C)
|
||||
- scope: application and presentation layers plus app wiring — **no domain or data change**; every repository contract from Stage 11A is consumed as-is
|
||||
- files added — `feature_orders` application:
|
||||
- `lib/src/application/create_custom_order.dart` — `CreateCustomOrder` use case wrapping `OrdersRepository.createCustomOrder`
|
||||
- `lib/src/application/record_deposit.dart` — `RecordDeposit` use case wrapping `OrdersRepository.recordDeposit`; accepts **no** payment identifier
|
||||
- `lib/src/application/custom_order_form_controller.dart` — `CustomOrderField` enum + `CustomOrderFormController` (`ChangeNotifier`): field state, field-keyed validation, `buildSpec()`, `submit()`, `reset()`, derived `estimatedPriceCents` / `depositAmountCents` / `balanceDueCents`, and the static `parseMoneyToCents()` money parser. Injectable clock and ID generator keep tests deterministic
|
||||
- files added — `feature_orders` presentation:
|
||||
- `lib/src/presentation/custom_order_form_page.dart` — `CustomOrderFormPage`; owns a controller unless one is injected, swaps to the confirmation page on success
|
||||
- `lib/src/presentation/custom_order_confirmation_page.dart` — `CustomOrderConfirmationPage` with the commission summary and the deposit receipt
|
||||
- `lib/src/presentation/widgets/fulfillment_status_chip.dart` — `FulfillmentStatusChip` over the design-system `KcStatusChip`
|
||||
- `lib/src/presentation/widgets/reference_image_dialog.dart` — `showReferenceImageDialog()` + `ReferenceImageDialogResult` with URL validation
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/presentation/orders_page.dart` — optional `onCreateCustomOrder` callback surfaces a "New Custom Order" toolbar action; omitted when the host has no intake route
|
||||
- `feature_orders/lib/feature_orders.dart` — barrel exports for all new application and presentation types
|
||||
- `feature_orders/README.md` — documents the intake UI and the cents-only money rule
|
||||
- `kell_web/lib/routing/app_routes.dart` — new `/orders/custom` route rendering `CustomOrderFormPage` inside `AppShell`; orders route now passes `onCreateCustomOrder`
|
||||
- `kell_web/lib/navigation/app_navigation.dart` — `ordersToCustomOrder()` and `customOrderToOrders()` handoffs
|
||||
- `kell_mobile/lib/pages/mobile_custom_order_page.dart` — **new** full-screen `SafeArea` host for the shared form
|
||||
- `kell_mobile/lib/pages/mobile_orders_page.dart` — extended FAB pushes the intake form and reloads the list on return
|
||||
- tests added (146 new):
|
||||
- `feature_orders/test/custom_order_form_controller_test.dart` — 82 tests (money parsing incl. separators/precision/negatives, initial state, setters, derived balance and clamping, reference image add/remove/dedupe, the full validation matrix, `buildSpec` mapping, submission, deposit quoting vs. collection, repository failure, duplicate ID, reset)
|
||||
- `feature_orders/test/widgets/custom_order_form_page_test.dart` — 26 widget tests (section and field rendering, validation feedback per field, running balance, deposit method reveal, reference image dialog paths, submission → confirmation, "New Custom Order" reset, `onComplete`)
|
||||
- `feature_orders/test/widgets/custom_order_confirmation_page_test.dart` — 24 widget tests (summary, spec rendering, deposit receipt quoted vs. paid, balance, **no payment identifier on screen**, actions, the 7-value `FulfillmentStatusChip` matrix)
|
||||
- `feature_orders/test/widgets/orders_page_test.dart` — 3 new tests for the custom order entry point
|
||||
- `kell_mobile/test/pages/mobile_custom_order_page_test.dart` — 6 tests (app bar, shared form host, `SafeArea`, sections, submit action, validation)
|
||||
- `kell_web/test/routing/custom_order_route_test.dart` — 5 tests (route constant, route renders the form in the shell, intake sections, orders action present, action opens the form)
|
||||
- privacy: the deposit receipt renders amount, method, date, and balance only — the transaction ID stays on `PaymentResult` and is asserted absent by test. The controller holds no payment identifier at any point
|
||||
- money: every amount is typed as text and converted to **integer cents** by `parseMoneyToCents` before it reaches `DepositInfo`; the only `double` is the derived `OrderItem.unitPrice`, computed once from cents
|
||||
- test-harness note: the intake form is taller than the default 800×600 test view, so the submit-button tests resize `tester.view` rather than working around a layout defect — the page scrolls normally on device
|
||||
- toolchain note: `kell_web/pubspec.lock` picked up SDK-pinned transitive bumps on `flutter pub get` (`matcher` 0.12.19→0.12.20, `meta` 1.18.0→1.19.0, `test_api` 0.7.11→0.7.12, `vector_math` 2.2.0→2.4.3). Same accepted class as the `kell_mobile` bumps recorded under `chore/state-reconciliation-flutter-347`; committed so the lock file matches the toolchain in use
|
||||
- tests: passed (792/792 feature_orders, 87/87 kell_mobile, 67/67 kell_web, 545/545 feature_wordpress, 167/167 feature_inventory, 123/123 integrations, 90/90 core, 63/63 data, 51/51 design_system, 42/42 auth, 17/17 feature_policy — **2044 total**)
|
||||
- analyze: passed (`dart analyze --fatal-infos` — no issues found across all 11 packages and apps)
|
||||
- brief updated: yes
|
||||
|
||||
### chore/state-reconciliation-flutter-347
|
||||
|
||||
- status: merged to main
|
||||
- branch: `chore/state-reconciliation-flutter-347`
|
||||
- stage: n/a — repository state reconciliation, no product code change
|
||||
- date: 2026-09-25
|
||||
- baseline: `92d83e0` (main, Stage 11A complete)
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- trigger: the working tree had drifted from `main` and local `main` was 4 commits ahead of `origin/main`
|
||||
- scope: **documentation and toolchain hygiene only** — no `lib/` or `test/` source file was modified
|
||||
|
||||
#### What the drift actually was
|
||||
|
||||
1. **Flutter 3.47.0 `analysis_options.yaml` churn (accepted).** The local toolchain moved from
|
||||
Flutter 3.44.x to **Flutter 3.47.0 / Dart 3.13.0**. Flutter 3.47 ships `AnalysisOptionsMigration`,
|
||||
which unconditionally writes a fixed seven-entry `analyzer.exclude` block
|
||||
(`build/**`, `android/**`, `ios/**`, `web/**`, `windows/**`, `macos/**`, `linux/**`)
|
||||
into **every** `analysis_options.yaml` it finds. Upstream bugs:
|
||||
[flutter#191160](https://github.com/flutter/flutter/issues/191160) and
|
||||
[flutter#191131](https://github.com/flutter/flutter/issues/191131).
|
||||
- Commit `92d83e0` previously tried to revert this. **That revert does not hold.** The migration
|
||||
runs from `ensureReadyForPlatformSpecificTooling`, so `flutter pub get`, `flutter analyze`,
|
||||
`flutter run`, and `flutter build` all rewrite the block. There is **no opt-out**.
|
||||
This was confirmed empirically: a full `flutter test` sweep regenerated the block in 8
|
||||
further packages that were clean beforehand.
|
||||
- **Decision: accept and commit the block** in all 11 packages/apps rather than fight the tool.
|
||||
Reverting it only produces a permanently dirty working tree and misleading diffs.
|
||||
- **Safety check:** the `web/**` entry is the dangerous one (it can hide real Dart sources in
|
||||
`dart create -t web` packages). Verified that neither `kell_web/web/` nor `kell_mobile/web/`
|
||||
contains any `.dart` file, so nothing is dropped from analysis here. `dart analyze
|
||||
--fatal-infos` remains clean across all 11 packages and apps.
|
||||
2. **`kell_mobile` web scaffold (reverted).** Someone had run `flutter create --platforms=web`
|
||||
inside `kell_mobile`, which produced an untracked `web/` directory (`index.html`,
|
||||
`manifest.json`, `favicon.png`, 4 icons) and rewrote `.metadata` so the `android` platform
|
||||
entry was **replaced** by `web`. `kell_mobile` is the Android target and its `android/`
|
||||
directory is tracked, so this was unintended tool output, not a deliberate platform addition.
|
||||
- **Decision: reverted.** `.metadata` restored to the tracked Android-only revision
|
||||
(`ad70ec46…`); the untracked `web/` scaffold deleted. `kell_mobile` remains Android-only.
|
||||
3. **`kell_mobile/pubspec.lock` transitive bumps (accepted).** Resolved under the newer SDK:
|
||||
`clock` 1.1.2→1.1.3, `matcher` 0.12.19→0.12.20, `meta` 1.18.0→1.18.3,
|
||||
`stack_trace` 1.12.1→1.12.2, `test_api` 0.7.11→0.7.12, `vector_math` 2.2.0→2.4.2,
|
||||
plus `vm_service`. All transitive, all pinned by the Flutter SDK. Committed so the lock file
|
||||
matches the toolchain that is actually in use.
|
||||
|
||||
#### Master document corrections
|
||||
|
||||
- `PROJECT_STATE_AUDIT.md` — **removed a duplicated `## 5. Summary Scorecard` section.** The file
|
||||
carried the entire scorecard and readiness table twice (lines 615–657 and 658–702), the second
|
||||
copy being a bad append. Removed the duplicate, leaving one authoritative §5.
|
||||
- `PROJECT_STATE_AUDIT.md` — added a reconciliation notice clarifying that §1–§4 are the preserved
|
||||
historical 2026-07-25 record while §5 is re-baselined to `main`.
|
||||
- `PROJECT_STATE_AUDIT.md` §3.4 — test counts corrected to the verified run
|
||||
(`core` 24→90, `design_system` 41→51, `feature_wordpress` 506→545, `feature_orders` 198→657,
|
||||
`feature_policy` ~5→17, `kell_web` 24→62, `kell_mobile` 26→81).
|
||||
- `PROJECT_STATE_AUDIT.md` §3.5 — marked three antipatterns resolved:
|
||||
`SquareCatalogSyncService` ID conflation (10E), no logging infrastructure (16A),
|
||||
no crash reporting (16A).
|
||||
- `PROJECT_STATE_AUDIT.md` §5 — scorecard re-baselined with a test-count column; Square POS,
|
||||
Square catalog/inventory sync, and observability moved to ✅; custom orders moved to ⚠️
|
||||
(domain+data done, UI open).
|
||||
- `master_development_brief.md` Appendix A — feature maturity matrix refreshed to the same
|
||||
verified counts and maturity levels.
|
||||
- `master_development_brief.md` — toolchain baseline recorded (Flutter 3.47.0 / Dart 3.13.0) with
|
||||
the `analysis_options.yaml` caveat so the next slice does not try to revert it again.
|
||||
|
||||
- tests: passed (**1,898 / 1,898** — core 90, design_system 51, auth 42, integrations 123, data 63,
|
||||
feature_wordpress 545, feature_inventory 167, feature_orders 657, feature_policy 17,
|
||||
kell_web 62, kell_mobile 81). Identical to the Stage 11A total, confirming this slice changed
|
||||
no behaviour.
|
||||
- analyze: passed (`dart analyze --fatal-infos` — no issues found across all 11 packages and apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/custom-order-domain
|
||||
|
||||
- status: merged to main
|
||||
- branch: `feat/custom-order-domain`
|
||||
- stage: 11A — Custom order domain and data
|
||||
- date: 2026-08-29
|
||||
- baseline: `666abd2` (main, Stage 16A complete, 1,475/1,475 passing)
|
||||
- commits: `86c630d` (implementation), `85501fd` (merge to main)
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- scope: domain and data layers only — no UI in this slice
|
||||
- closes audit findings: P2.2 (domain + data portion; UI remains open as Stage 11B/11C) and P3.8 (`feature_orders` scaffolding files)
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/domain/order_type.dart` — new `OrderType` enum (standard/custom) with `label`, `wireValue`, `fromWire()` defaulting to `standard`
|
||||
- `feature_orders/lib/src/domain/custom_order_spec.dart` — new immutable `CustomOrderSpec` (material, color, size, dimensions, unmodifiable `referenceImageUrls`, notes, `estimatedCompletionDate`); `copyWith`, list-aware `==`/`hashCode`, `toString`, `isProductionReady`, `hasReferenceImages`, `summary`
|
||||
- `feature_orders/lib/src/domain/deposit_info.dart` — new immutable `DepositInfo`; money as integer cents (`depositAmountCents`, `totalAmountCents`), `depositDate`, `settlementDate`, `depositPaid`, derived `balanceDueCents` (clamped at zero), `markDepositPaid()`, `markSettled()`, `formatCents()`, `.quoted()`; deliberately carries **no** payment identifier
|
||||
- `feature_orders/lib/src/domain/fulfillment_status.dart` — new `FulfillmentStatus` (quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed, plus cancelled) with `label`, `stageIndex`, `pipeline`, `wireValue`, `fromWire()`; `FulfillmentTransition` extension with `allowedNextStatuses`, `canTransitionTo()`, `rejectionReason()`, `isTerminal`, `isSuccessful`, `requiresDeposit`
|
||||
- `feature_orders/lib/src/domain/order.dart` — added `orderType` (defaults to `standard`), nullable `customOrderSpec`, `depositInfo`, `fulfillmentStatus`; added `isCustom`, `hasDeposit`, `balanceDueCents`; all existing fields, defaults, and `copyWith` semantics preserved
|
||||
- `feature_orders/lib/src/domain/orders_repository.dart` — added `createCustomOrder()`, `updateCustomOrderSpec()`, `recordDeposit()`, `advanceFulfillmentStatus()`
|
||||
- `feature_orders/lib/src/data/fake_orders_repository.dart` — implemented all four methods; injected `KcLogger` (defaults to `KcLogger.silent()`); five seeded custom orders `KC-C2001`…`KC-C2005` covering every non-terminal pipeline stage; existing rejection paths now logged via `_rejected()`
|
||||
- `feature_orders/lib/src/data/woo_commerce_orders_repository.dart` — implemented all four methods; injected `KcLogger`; `_guard()` converts every exception into a logged failure result; `_loadCustomOrder()` validates existence and order type before any write; `_pushCustomOrderMeta()` sends only `_kc_` meta so commercial fields are never disturbed
|
||||
- `feature_orders/lib/src/data/woo_commerce_order_mapper.dart` — 13 `_kc_`-prefixed meta keys with a documented mapping table; `customOrderMetaData()`, `specMetaEntries()`, `depositMetaEntries()`, `toMetaDataList()`; `fromJson()` reconstructs spec/deposit/type/stage; internal `_`-prefixed keys stay out of `Order.customFields`
|
||||
- `feature_orders/lib/feature_orders.dart` — barrel updated with 4 new domain exports
|
||||
- `feature_orders/pubspec.yaml` — added `core` path dependency (for `KcLogger`); description expanded
|
||||
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — new `updateOrder(orderId, body)` generic order PUT, mirroring `updateProduct()`
|
||||
- `feature_orders/.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` — P3.8 scaffolding (`.gitignore` already existed)
|
||||
- `feature_orders/test/fake_orders_repository_test.dart` — seeded-count assertions updated 6 → 11 and 7 → 12; added a standard-order count test
|
||||
- `feature_orders/test/orders_controller_test.dart` — count assertions updated for the larger seed set (11 total, 3 pending)
|
||||
- `feature_orders/test/widgets/orders_page_test.dart` — order-count label updated to `11 orders`; `_EmptyOrdersRepository` satisfies the widened contract
|
||||
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — `_StubOrdersRepository` satisfies the widened contract
|
||||
- tests added:
|
||||
- `feature_orders/test/order_type_test.dart` — 12 tests (enum shape, labels, wire round-trip, fallbacks)
|
||||
- `feature_orders/test/custom_order_spec_test.dart` — 46 tests (construction, unmodifiable list, derived state, `summary` PII safety, `copyWith`, equality, `toString` leak checks)
|
||||
- `feature_orders/test/deposit_info_test.dart` — 58 tests (cents arithmetic, balance derivation and clamping, formatting parity with `PaymentResult`, transitions, `copyWith`, equality, no-payment-identifier check)
|
||||
- `feature_orders/test/fulfillment_status_test.dart` — 150 tests including the **exhaustive 7 × 7 transition matrix**: every legal pair asserted allowed, every other pair asserted rejected with an explanatory reason
|
||||
- `feature_orders/test/fake_orders_repository_custom_test.dart` — 77 tests (seeded data shape, all four new methods, legal and illegal advances, log-context assertions, PII and money redaction checks)
|
||||
- `feature_orders/test/woo_commerce_custom_order_test.dart` — 79 tests (meta read/write, round trip through every pipeline stage, malformed-meta tolerance, all four methods over `MockClient`, observability on pre-existing operations, no-payment-identifier-on-the-wire checks)
|
||||
- observability: `KcLogger` injected into both repositories; rejections logged at `warning`, API failures at `error` with status code and originating exception; **no silent `catch (_) {}`**; log context limited to identifiers, operation names, and status names — never customer identity, addresses, money amounts, or payment identifiers; all context additionally passes through `KcRedactor`
|
||||
- WooCommerce mapping: custom order state persisted as 13 `_kc_`-prefixed order meta keys (`_kc_order_type`, `_kc_fulfillment_status`, `_kc_spec_*`, `_kc_deposit_*`, `_kc_settlement_date`). `balanceDueCents` is derived on read, never persisted. The deposit transaction ID is deliberately excluded — order meta is readable by any store administrator
|
||||
- tests: passed (1898/1898 total; feature_orders 657/657 +423; all other packages unchanged — core 90, design_system 51, auth 42, integrations 123, data 63, feature_wordpress 545, feature_inventory 167, kell_web 62, kell_mobile 81, feature_policy 17)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages and apps)
|
||||
- format: `dart format --line-length 100` applied to all files touched by this slice (matches the repo's `.vscode/settings.json` `dart.lineLength: 100`); `--set-exit-if-changed` clean across `feature_orders`, and the two files touched in `feature_wordpress` / `kell_web`
|
||||
- known pre-existing issues (not introduced here, left untouched): `kell_web/lib/composition/app_services.dart` and `kell_web/lib/shell/app_nav_item.dart` are not format-clean at 100 cols; `tools/run_all_tests.sh` reports `TOTAL 0` under Git Bash on Windows because its `grep` for the `✓` glyph does not match the console encoding — per-package "All tests passed!" lines and the analyze gate remain accurate
|
||||
- brief updated: yes
|
||||
|
||||
### feat/observability-error-logging
|
||||
|
||||
- status: merged to main
|
||||
- branch: `feat/observability-error-logging`
|
||||
- stage: 16A — Error logging, crash reporting, and bug reporting
|
||||
- date: 2026-08-29
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- closes audit findings: P3.4 (structured logging), P3.5 (crash reporting), and the "No logging infrastructure" / "No crash reporting" architectural risks in `PROJECT_STATE_AUDIT.md` §3.4
|
||||
- files changed:
|
||||
- `core/lib/src/observability/kc_log_level.dart` — new `KcLogLevel` enum (debug/info/warning/error/fatal) with `severity`, `label`, `isAtLeast()`, `fromString()`
|
||||
- `core/lib/src/observability/kc_log_record.dart` — new immutable `KcLogRecord` (level, source, message, timestamp, unmodifiable context, error, stackTrace); `format()`, `toJson()`, `isFailure`
|
||||
- `core/lib/src/observability/kc_redactor.dart` — new `KcRedactor`; `isSensitiveKey()`, `redactMap()` (recurses nested maps/lists), `maskValue()`; 14 sensitive key fragments
|
||||
- `core/lib/src/observability/kc_log_sink.dart` — new `KcLogSink` contract; `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer with `recent()`/`recordsAtLeast()`/`clear()`), `KcMultiLogSink` (fault-tolerant fan-out)
|
||||
- `core/lib/src/observability/kc_logger.dart` — new `KcLogger` facade; level helpers, threshold filtering, automatic context redaction, `forSource()`, `KcLogger.silent()`; never throws
|
||||
- `core/lib/src/observability/kc_error_reporter.dart` — new `KcErrorReporter`; `install()` wires `FlutterError.onError` + `PlatformDispatcher.instance.onError`, `runGuarded()` wraps `runApp`, `restore()` for test isolation
|
||||
- `core/lib/src/observability/kc_bug_report.dart` — new `KcBugSeverity` enum and `KcBugReport` model with redacted diagnostics, `hasFailureEvidence`, `toJson()`, `toMarkdown()`
|
||||
- `core/lib/src/observability/kc_bug_report_service.dart` — new `KcBugReportResult`, `KcBugReportSink` contract, `KcInMemoryBugReportSink`, and `KcBugReportService` (validation, log-tail evidence, graceful sink-failure handling)
|
||||
- `core/lib/src/observability/kc_observability.dart` — new `KcObservability` bundle; `standard()` and `silent()` factories guarantee logger and bug report service share one evidence buffer
|
||||
- `core/lib/core.dart` — barrel updated with all 9 observability exports
|
||||
- `design_system/lib/src/widgets/kc_bug_report_dialog.dart` — new `KcBugReportDraft` and `KcBugReportDialog` (title/description/severity/reporter, inline validation, no-credentials notice)
|
||||
- `design_system/lib/design_system.dart` — barrel updated
|
||||
- `kell_web/lib/composition/app_services.dart` — `observability` field + `logger`/`bugReportService` accessors + `appVersion`; all 4 factories stamp environment labels (FAKE/WP/SQ/MULTI)
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — mirrored observability wiring across all 4 factories
|
||||
- `kell_web/lib/main.dart` — `KcErrorReporter.install()` + `runGuarded()` + startup record
|
||||
- `kell_mobile/lib/main.dart` — mirrored error handler installation and startup record
|
||||
- `kell_web/lib/shell/app_shell.dart` — `_ReportProblemAction` app bar action wired to `bugReportService`
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — `_reportProblem()` app bar action capturing the active tab
|
||||
- tests added:
|
||||
- `core/test/observability_test.dart` — 56 tests across `KcLogLevel`, `KcRedactor`, `KcLogRecord`, `KcInMemoryLogSink`, `KcMultiLogSink`, `KcLogger`, `KcErrorReporter`, `KcBugSeverity`, `KcBugReport`, `KcBugReportService`
|
||||
- `design_system/test/kc_bug_report_dialog_test.dart` — 10 tests (rendering, screen context, validation, trimming, severity selection, cancel)
|
||||
- `kell_web/test/shell/bug_report_action_test.dart` — 10 tests (service wiring, shared buffer, environment labels, shell submission flow)
|
||||
- `kell_mobile/test/shell/bug_report_action_test.dart` — 9 tests (service wiring, shared buffer, shell submission flow, active-tab capture)
|
||||
- tests: passed (1475/1475 total; core 90/90 +56; design_system 51/51 +10; kell_web 62/62 +10; kell_mobile 81/81 +9)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages and apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-mobile-pos
|
||||
|
||||
- status: in progress (ready to merge)
|
||||
- branch: `feat/square-mobile-pos`
|
||||
- stage: 10C — Square Mobile POS / Checkout
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/domain/payment_method.dart` — `PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions
|
||||
- `feature_orders/lib/src/domain/payment_result.dart` — `PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted)
|
||||
- `feature_orders/lib/src/application/payment_service.dart` — `PaymentService` abstract contract + `PaymentException`
|
||||
- `feature_orders/lib/src/application/checkout_controller.dart` — `CheckoutController` (ChangeNotifier), `CartEntry`, sealed `CheckoutState` hierarchy (idle/processing/success/error)
|
||||
- `feature_orders/lib/src/data/fake_payment_service.dart` — `FakePaymentService` (always succeeds, counter-based IDs)
|
||||
- `feature_orders/lib/feature_orders.dart` — barrel updated with all new exports
|
||||
- `kell_mobile/lib/pages/mobile_checkout_page.dart` — `MobileCheckoutPage` (product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback)
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Sales tab (index 4) added; `CheckoutController` initialised in `didChangeDependencies`; `_titles` updated to 6 entries
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — `paymentService` field + `checkoutCatalogue` field; `_fakeCatalogue` const; all factory constructors updated
|
||||
- `kell_mobile/test/widget_test.dart` — updated destination count assertion 5 → 6
|
||||
- tests added:
|
||||
- `feature_orders/test/payment_method_test.dart` — 5 tests
|
||||
- `feature_orders/test/payment_result_test.dart` — 11 tests
|
||||
- `feature_orders/test/fake_payment_service_test.dart` — 4 tests
|
||||
- `feature_orders/test/checkout_controller_test.dart` — 16 tests
|
||||
- `kell_mobile/test/composition/mobile_app_services_test.dart` — 11 new tests (paymentService + checkoutCatalogue groups)
|
||||
- `kell_mobile/test/pages/mobile_checkout_page_test.dart` — 11 widget tests
|
||||
- tests: passed (1373/1373 total; feature_orders 234/234 +36; kell_mobile 72/72 +18)
|
||||
- analyze: passed (dart analyze — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/description-only-edit
|
||||
|
||||
- status: merged to main
|
||||
|
|
@ -356,8 +44,7 @@
|
|||
|
||||
### feat/publishing-ux-hardening
|
||||
|
||||
- status: merged to main
|
||||
- commit: `b81016d`
|
||||
- status: in progress (Stage 2B)
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
|
|
@ -368,638 +55,3 @@
|
|||
- tests: passed (247/247 feature_wordpress)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/multi-select-groundwork
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added multi-selection state (`toggleMultiSelect`, `clearMultiSelection`, `selectAllVisible`, `isMultiSelected`, `multiSelectedIds`, `multiSelectedCount`)
|
||||
- `feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart` — added optional leading checkbox for multi-select mode
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_MultiSelectBar` with selected-count display, Select All, and Clear actions
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 15 new multi-select tests
|
||||
- tests: passed (262/262 feature_wordpress)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/list-efficiency-improvements
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `ListDensity` enum, compact/standard view toggle, staleness detection (`isStale()`, `staleCount()` with 30-day threshold), keyboard navigation (`selectNextDraft`/`selectPreviousDraft` with wrapping)
|
||||
- `feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart` — added compact two-row layout variant, description snippet in standard card, stale indicator icon, `Flexible` overflow handling
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — wired `Focus`+`onKeyEvent` for arrow keys and density toggle button
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 32 new tests
|
||||
- tests: passed (294/294 feature_wordpress, 5/5 kell_web dashboard)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/design-system-shared-widgets
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `design_system/lib/design_system.dart` — expanded barrel exports with typography, layout, and 5 new widgets
|
||||
- `design_system/lib/src/theme/kc_typography.dart` — new shared typography scale (KcTypography) with full Material 3 text style hierarchy and `applyKcTypography()` helper
|
||||
- `design_system/lib/src/theme/kc_theme.dart` — updated to use `KcTypography.applyKcTypography()` instead of inline text styles
|
||||
- `design_system/lib/src/layout/kc_breakpoints.dart` — new responsive breakpoint utilities (KcBreakpoints) with compact/medium/expanded/large queries and grid column helper
|
||||
- `design_system/lib/src/widgets/kc_empty_state.dart` — migrated from kell_web EmptyStatePanel as KcEmptyState
|
||||
- `design_system/lib/src/widgets/kc_section_header.dart` — migrated from kell_web SectionHeader as KcSectionHeader
|
||||
- `design_system/lib/src/widgets/kc_summary_card.dart` — migrated from kell_web SummaryCard as KcSummaryCard
|
||||
- `design_system/lib/src/widgets/kc_loading_state.dart` — new shared loading state widget
|
||||
- `design_system/lib/src/widgets/kc_error_state.dart` — new shared error state widget with optional retry
|
||||
- `design_system/test/design_system_test.dart` — expanded from 3 to 41 tests covering all new widgets, typography, breakpoints, colors, spacing
|
||||
- `kell_web/lib/pages/dashboard_page.dart` — updated imports to use design_system widgets directly (KcSectionHeader, KcSummaryCard, KcEmptyState)
|
||||
- `kell_web/lib/shell/widgets/empty_state_panel.dart` — replaced with backward-compatible typedef re-export
|
||||
- `kell_web/lib/shell/widgets/section_header.dart` — replaced with backward-compatible typedef re-export
|
||||
- `kell_web/lib/shell/widgets/summary_card.dart` — replaced with backward-compatible typedef re-export
|
||||
- tests: passed (41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web)
|
||||
- analyze: passed (dart analyze — no issues found in design_system and kell_web)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/shared-composition-pattern
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `core/lib/src/app/` — extracted shared composition abstractions (KcAppConfig, KcAppEnvironment, KcAppServices, KcServiceFactory, KcBootstrap, KcAppScope)
|
||||
- `core/lib/core.dart` — expanded barrel exports for app composition
|
||||
- `core/test/core_test.dart` — added 20 tests for shared composition abstractions
|
||||
- `kell_web/` — updated to use core composition abstractions
|
||||
- tests: passed (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/flutter-cicd
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `.forgejo/workflows/flutter-analyze.yml` — new Forgejo Actions workflow for dart analyze on all 8 packages/apps
|
||||
- `.forgejo/workflows/flutter-test.yml` — new Forgejo Actions workflow for flutter test per package with aggregate pass/fail summary
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — new local CI helper script with optional --analyze flag
|
||||
- `kell_creations_apps/tools/README.md` — documents scripts and CI workflow inventory
|
||||
- tests: passed (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web — 379 total)
|
||||
- analyze: passed (dart analyze — no issues found across all 8 packages/apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/test-coverage-visibility
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `.forgejo/workflows/flutter-test.yml` — enhanced to run flutter test --coverage, parse lcov.info, report per-package line coverage percentages in aggregate summary table
|
||||
- `kell_creations_apps/tools/collect_coverage.sh` — new local coverage helper script with summary table
|
||||
- `kell_creations_apps/tools/README.md` — documented collect_coverage.sh script
|
||||
- `.gitignore` — added coverage/ directories
|
||||
- `docs/development/master_development_brief.md` — marked Stage 4D complete, documented baseline coverage table, updated next branch to Stage 5A, partially resolved improvement #5
|
||||
- tests: passed (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web — 379 total)
|
||||
- analyze: passed
|
||||
- coverage baseline: core 85.7%, design_system 100.0%, feature_wordpress 84.7%, kell_web 54.1%, overall 78.4%
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-app-shell
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-28
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `kell_mobile/pubspec.yaml` — replaced default template dependencies with shared packages (`core`, `design_system`, `feature_inventory`, `feature_orders`, `feature_policy`, `feature_wordpress`); SDK constraint corrected to `^3.11.0`
|
||||
- `kell_mobile/lib/main.dart` — replaced counter template with `KcBootstrap` entry point using `--dart-define` environment variables
|
||||
- `kell_mobile/lib/app.dart` — new `KellMobileApp` widget with `KcAppScope<MobileAppServices>`, `KcTheme`, and environment badge
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — new `MobileAppServices` extending `KcAppServices` with `fake()` and `wp()` factory constructors
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — new `MobileShell` with 5-tab `NavigationBar` (Dashboard, Inventory, Orders, Publishing, More) and `IndexedStack` body
|
||||
- `kell_mobile/lib/dashboard/domain/dashboard_summary.dart` — shared `DashboardSummary` value object with `fromData()` and `empty()` constructors
|
||||
- `kell_mobile/lib/dashboard/application/get_dashboard_summary.dart` — use case aggregating inventory, orders, and publishing repositories
|
||||
- `kell_mobile/lib/dashboard/application/dashboard_controller.dart` — `ChangeNotifier` controller with loading/error/summary state
|
||||
- `kell_mobile/lib/pages/dashboard_page.dart` — mobile-optimized dashboard with `GridView` summary cards using design system widgets
|
||||
- `kell_mobile/lib/pages/finance_placeholder_page.dart` — placeholder page for Finance tab
|
||||
- `kell_mobile/lib/pages/integrations_placeholder_page.dart` — placeholder page for Integrations tab
|
||||
- `kell_mobile/test/widget_test.dart` — 6 widget tests covering shell loading, summary cards, environment badge, navigation bar, tab switching, and More menu
|
||||
- 13 other `pubspec.yaml` files — SDK constraint corrected from `^3.11.4` to `^3.11.0` across all packages
|
||||
- tests: passed (6/6 kell_mobile, 24/24 kell_web, 294/294 feature_wordpress — all passing)
|
||||
- analyze: not yet run (SDK constraint fix was prerequisite)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-publishing-surface
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-29
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — expanded barrel exports with `ProductPublishingController`, `ProductSortField`, `ProductDraftCard`, `ProductPreviewPanel`, and all 5 use cases + snack bar helpers for mobile consumption
|
||||
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — new mobile-optimized publishing workspace with search bar, horizontal filter chips, sort dropdown, product count, compact card list, pull-to-refresh, and push navigation to detail page
|
||||
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — new full-screen product detail page wrapping shared `ProductPreviewPanel` with all narrow edit callbacks
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Products tab (case 2) switched from `ProductPublishingPage` to `MobilePublishingPage`; removed unused `feature_wordpress` import
|
||||
- `kell_mobile/test/widget_test.dart` — added 4 new mobile publishing surface tests (search bar, filter chips, product count, sort button) — 10 total kell_mobile tests
|
||||
- tests: passed (10/10 kell_mobile)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-feedback-polish
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-29
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — converted from stateless to StatefulWidget; added local controller listener for SnackBar feedback in detail page context; added confirmation dialogs for publish/move-to-draft actions; added haptic feedback (mediumImpact for status, lightImpact for field edits) on successful actions
|
||||
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — added `_detailPageActive` guard to suppress SnackBars when detail page is pushed (prevents invisible behind-route feedback); updated `_navigateToDetail` to set/clear the guard flag using Navigator.push().then()
|
||||
- `kell_mobile/test/widget_test.dart` — added 4 new Stage 6A tests: detail page navigation, confirmation dialog for status changes, product name in app bar, and back navigation returning to product list
|
||||
- tests: passed (14/14 kell_mobile)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-mobile-ux-hardening
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart` — removed `constraints: BoxConstraints()` and `padding: EdgeInsets.zero` overrides from 12 `IconButton`s (edit/save/cancel for name, price, description, category) to restore Material Design 48×48dp minimum touch targets; replaced fixed-width `SizedBox(width: 80)` on price edit `TextField` with `Expanded` for flexible layout on narrow mobile screens; added `tooltip: 'Edit price'` for consistency with other edit buttons
|
||||
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — wrapped `ProductPreviewPanel` in `SafeArea` to prevent content clipping under system UI on devices with notches/gesture bars
|
||||
- `feature_wordpress/test/widgets/product_preview_panel_test.dart` — added 6 new touch target tests: rendered size ≥ 48×48dp verification for edit name, edit price, edit category, edit description, save/cancel name buttons, and flexible price edit TextField width
|
||||
- tests: passed (300/300 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system — 379 total)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/bulk-status-actions
|
||||
|
||||
- status: merged to main
|
||||
- commit: `2c3ed3b`
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `BulkActionResult` value class with `successCount`, `failureCount`, `targetStatus`, `failedProductNames`, `totalCount`, `allSucceeded`; added `lastBulkActionResult`, `consumeBulkActionResult()`, and `bulkUpdateStatus()` method that processes selected products sequentially with per-row updating state, then reloads once and clears multi-selection
|
||||
- `feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart` — added `showBulkActionSnackBar()` helper with success/partial-failure/total-failure variants showing count summaries and up to 3 failed product names
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_confirmBulkMoveToDraft()` confirmation dialog; updated `_MultiSelectBar` with `onBulkMoveToDraft` callback and "Move to Draft" `OutlinedButton.icon`; added bulk result listener in `_onControllerChanged`
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 11 new `bulkUpdateStatus` tests: no-op on empty selection, moves all to draft, sets result on all-success, clears multi-selection, clears updatingIds, handles mixed statuses, consume clears result, starts null, preserves preview selection, persists filter/sort, disposal safety
|
||||
- tests: passed (311/311 feature_wordpress, 24/24 kell_web — 335+ total)
|
||||
- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/inventory-domain-expansion
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-10
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_inventory/lib/src/domain/inventory_item.dart` — expanded model with `description`, `imageUrl`, `category`, `reorderPoint`, `supplier`, `location`, `lastUpdated` fields; added `copyWith()`
|
||||
- `feature_inventory/lib/src/domain/inventory_adjustment_result.dart` — new value object with `success`, `updatedItem`, `errorMessage`; named constructors `success()` and `failure()`
|
||||
- `feature_inventory/lib/src/domain/inventory_repository.dart` — expanded contract with `adjustQuantity()`, `setQuantity()`, `createItem()`, `updateItem()`, `updateItemImage()`
|
||||
- `feature_inventory/lib/src/data/fake_inventory_repository.dart` — expanded seed data (6 items with full fields, supplier/location, reorderPoint); implemented all write methods with status auto-update logic
|
||||
- `feature_inventory/lib/src/application/adjust_inventory_quantity.dart` — new use case wrapping `adjustQuantity()`
|
||||
- `feature_inventory/lib/src/application/create_inventory_item.dart` — new use case wrapping `createItem()`
|
||||
- `feature_inventory/lib/src/application/update_inventory_item.dart` — new use case wrapping `updateItem()`
|
||||
- `feature_inventory/lib/src/application/inventory_controller.dart` — added `isUpdating`, `lastActionResult`, `adjustQuantity()`, `createItem()`, `updateItem()`, `_refreshItem()` with pre-filter selectedItem refresh
|
||||
- `feature_inventory/lib/src/presentation/inventory_page.dart` — updated `InventoryController` constructor call to pass all 4 use cases
|
||||
- `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for all new types and use cases
|
||||
- `feature_inventory/test/feature_inventory_test.dart` — 49 tests covering InventoryStatus, InventoryItem, InventoryAdjustmentResult, FakeInventoryRepository (all write methods), and InventoryController (read + write actions)
|
||||
- `kell_web/test/dashboard/domain/dashboard_summary_test.dart` — updated InventoryItem construction to use expanded model
|
||||
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated stub InventoryRepository to implement expanded contract
|
||||
- tests: passed (49/49 feature_inventory, 24/24 kell_web, 20/20 core, 41/41 design_system, 311/311 feature_wordpress — 445 total)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/inventory-page-ui
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-10
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_inventory/lib/src/presentation/inventory_page.dart` — full inventory page with search bar, status filter chips (All / In Stock / Low Stock / Out of Stock / Draft), item count display, `InventoryItemCard` list, `initialFilter` and `initialQuery` constructor params
|
||||
- `feature_inventory/lib/src/presentation/widgets/inventory_status_chip.dart` — `InventoryStatusChip` widget mapping `InventoryStatus` to label and color
|
||||
- `feature_inventory/lib/src/presentation/widgets/inventory_detail_panel.dart` — `InventoryDetailPanel` with name, SKU, description, quantity, price, status chip, supplier, location, reorder point, last updated; Adjust Quantity and View Product action buttons; `isUpdating` loading indicator
|
||||
- `feature_inventory/lib/src/presentation/widgets/adjust_quantity_dialog.dart` — `AdjustQuantityDialog` with +/- delta input, reason field, and `AdjustQuantityDialogResult` value object
|
||||
- `feature_inventory/lib/src/presentation/inventory_action_snack_bar.dart` — `showInventoryActionSnackBar()` helper for success/failure feedback
|
||||
- `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for all new presentation types
|
||||
- `feature_inventory/test/widgets/inventory_page_test.dart` — 26 new widget tests covering `InventoryStatusChip` (4), `InventoryDetailPanel` (11), `InventoryPage` (9), `AdjustQuantityDialogResult` (2)
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — added `feature_inventory` to `TESTABLE` list
|
||||
- tests: passed (75/75 feature_inventory, 311/311 feature_wordpress, 24/24 kell_web, 14/14 kell_mobile, 41/41 design_system, 20/20 core — 485 total)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/orders-domain
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-10
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/domain/order.dart` — added `customerPhone`, `notes`, `orderNotes` fields; added `copyWith()`
|
||||
- `feature_orders/lib/src/domain/order_action_result.dart` — new value object with `success`, `updatedOrder`, `errorMessage`; named constructors `success()` and `failure()`
|
||||
- `feature_orders/lib/src/domain/orders_repository.dart` — expanded contract with `updateOrderStatus()`, `addOrderNote()`, `createOrder()`
|
||||
- `feature_orders/lib/src/data/fake_orders_repository.dart` — added `customerPhone` to seed data; implemented all write methods with duplicate-check, empty-note guard, and status auto-update
|
||||
- `feature_orders/lib/src/application/update_order_status.dart` — new use case wrapping `updateOrderStatus()`
|
||||
- `feature_orders/lib/src/application/add_order_note.dart` — new use case wrapping `addOrderNote()`
|
||||
- `feature_orders/lib/src/application/create_order.dart` — new use case wrapping `createOrder()`
|
||||
- `feature_orders/lib/src/application/orders_controller.dart` — added `isUpdating`, `lastActionResult`, `updateOrderStatus()`, `addOrderNote()`, `createOrder()`, `consumeActionResult()`, `_reloadAndRefresh()` with filter/search persistence after writes; write use cases are optional named params for backward compatibility
|
||||
- `feature_orders/lib/feature_orders.dart` — expanded barrel exports for all new types and use cases
|
||||
- `feature_orders/test/feature_orders_test.dart` — expanded domain tests: Order (copyWith, defaults, computed props), OrderItem, OrderStatus, OrderActionResult, barrel smoke test
|
||||
- `feature_orders/test/fake_orders_repository_test.dart` — expanded with write method tests: updateOrderStatus (4), addOrderNote (6), createOrder (4)
|
||||
- `feature_orders/test/orders_controller_test.dart` — expanded with write-side tests: updateOrderStatus (6), addOrderNote (6), createOrder (4), consumeActionResult (3), filter/search persistence (2); read-side tests preserved
|
||||
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubOrdersRepository` to implement expanded `OrdersRepository` contract
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — added `feature_orders` and `kell_mobile` to `TESTABLE` list
|
||||
- tests: passed (90/90 feature_orders, 24/24 kell_web — all passing)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders and kell_web)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/orders-page-ui
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-10
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/presentation/orders_page.dart` — full orders page with search bar, status filter chips (All / Pending / Processing / Shipped / Delivered / Cancelled), order count display, `OrderCard` list, master-detail wide layout, push-navigation narrow layout; `initialFilter`, `initialQuery`, `initialSelectedId` constructor params; `onViewProduct` and `onViewInventory` cross-feature callbacks
|
||||
- `feature_orders/lib/src/presentation/widgets/order_card.dart` — `OrderCard` with order ID, customer name, date, status chip, item count, and total
|
||||
- `feature_orders/lib/src/presentation/widgets/order_detail_panel.dart` — `OrderDetailPanel` with customer info, shipping address, line items table, order total, order notes, status update dropdown, Add Note button, and `isUpdating` loading indicator; optional `onViewProduct`/`onViewInventory` cross-feature links per line item
|
||||
- `feature_orders/lib/src/presentation/widgets/order_status_chip.dart` — `OrderStatusChip` mapping `OrderStatus` to label and color
|
||||
- `feature_orders/lib/src/presentation/widgets/add_note_dialog.dart` — `AddNoteDialog` with text field, empty-note validation, and `AddNoteDialogResult` value object
|
||||
- `feature_orders/lib/src/presentation/orders_action_snack_bar.dart` — `showOrderActionSnackBar()` helper for success/failure feedback
|
||||
- `feature_orders/lib/feature_orders.dart` — expanded barrel exports for all new presentation types and `OrdersController`
|
||||
- `feature_orders/test/widgets/orders_page_test.dart` — 25 new widget tests covering `OrderDetailPanel` (14), `AddNoteDialog` (3), `OrdersPage` (6), and `_EmptyOrdersRepository` test double
|
||||
- tests: passed (115/115 feature_orders, 24/24 kell_web — 139 total for affected packages)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/orders-mobile-surface
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-10
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `kell_mobile/lib/pages/mobile_orders_page.dart` — new `MobileOrdersPage` with search bar, horizontal status filter chips (All / Pending / Processing / Shipped / Delivered / Cancelled), order count display, `OrderCard` list at 160px height, pull-to-refresh, `_detailPageActive` guard to suppress behind-route SnackBars
|
||||
- `kell_mobile/lib/pages/mobile_order_detail_page.dart` — new `MobileOrderDetailPage` wrapping shared `OrderDetailPanel`; StatefulWidget with local controller listener for SnackBar feedback; confirmation dialog before status changes; haptic feedback (mediumImpact for status, lightImpact for note additions); `SafeArea` wrapper
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Orders tab (case 3) switched from shared `OrdersPage` to `MobileOrdersPage`; removed unused `feature_orders` direct import
|
||||
- `kell_mobile/test/widget_test.dart` — added 6 new Stage 6B tests: Orders tab search bar, status filter chips, order count, order card navigation to detail, confirmation dialog for status change, back navigation returning to orders list — 20 total kell_mobile tests
|
||||
- tests: passed (20/20 kell_mobile, 115/115 feature_orders — all passing)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/inventory-mobile-surface
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-10
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `kell_mobile/lib/pages/mobile_inventory_page.dart` — new `MobileInventoryPage` with search bar, horizontal status filter chips (All / In Stock / Low Stock / Out of Stock / Draft), item count display, compact `_MobileInventoryCard` list at 120px height showing name, SKU, status chip, and quantity-on-hand, pull-to-refresh, `_detailPageActive` guard to suppress behind-route SnackBars
|
||||
- `kell_mobile/lib/pages/mobile_inventory_detail_page.dart` — new `MobileInventoryDetailPage` wrapping shared `InventoryDetailPanel`; StatefulWidget with local controller listener for SnackBar feedback; shows `showAdjustQuantityDialog` on Adjust Quantity tap; haptic feedback (mediumImpact on result, lightImpact on successful adjust); `SafeArea` wrapper
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Inventory tab (case 1) switched from shared `InventoryPage` to `MobileInventoryPage`; removed unused `feature_inventory` direct import
|
||||
- `kell_mobile/test/widget_test.dart` — added 6 new Stage 6C tests: Inventory tab search bar, status filter chips, item count, inventory card navigation to detail, adjust quantity dialog, back navigation returning to inventory list — 26 total kell_mobile tests
|
||||
- tests: passed (26/26 kell_mobile, 75/75 feature_inventory — all passing)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### chore/analyze-cleanup-and-tracker-sync
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — removed 3 unnecessary_import directives (get_product_drafts, product_publishing_controller, publish_product)
|
||||
- `feature_wordpress/test/widgets/product_draft_card_test.dart` — removed 1 unnecessary_import directive (product_draft_card)
|
||||
- `feature_wordpress/test/widgets/product_preview_panel_test.dart` — removed 1 unnecessary_import directive (product_preview_panel)
|
||||
- `feature_wordpress/test/widgets/publish_status_chip_test.dart` — removed 1 unnecessary_import directive (publish_status_chip)
|
||||
- `feature_wordpress/test/widgets/status_action_snack_bar_test.dart` — removed 2 unnecessary_import directives (product_publishing_controller, status_action_snack_bar)
|
||||
- `kell_mobile/test/widget_test.dart` — removed unused `productCards` local variable
|
||||
- `docs/development/build_execution_tracker.md` — updated Stage 7A to merged, synced current status
|
||||
- `docs/development/master_development_brief.md` — updated next recommended branch, validation state, test counts
|
||||
- tests: passed (311/311 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system, 20/20 core — 410 total)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/integrations-contracts
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `integrations/lib/src/api_exception.dart` — new `ApiException` base class with `statusCode`, `message`, `body`; replaces placeholder `Calculator` class
|
||||
- `integrations/lib/src/api_client.dart` — new `ApiClient` abstract class with `initialize()`, `dispose()`, `isReady`, `execute<T>()` lifecycle contract
|
||||
- `integrations/lib/src/retry_policy.dart` — new `RetryPolicy` abstract class; `NoRetryPolicy` (never retries); `ExponentialBackoffRetryPolicy` (doubles delay per attempt, capped at `maxDelay`, retries on 5xx and network errors)
|
||||
- `integrations/lib/src/rate_limiter.dart` — new `RateLimiter` abstract class; `NoRateLimiter` (no-op); `TokenBucketRateLimiter` (maxRequests per window, queues excess requests)
|
||||
- `integrations/lib/src/integration_health_check.dart` — new `HealthCheckResult` value object with `healthy`/`unhealthy` named constructors; `IntegrationHealthCheck` abstract contract
|
||||
- `integrations/lib/src/webhook_handler.dart` — new `WebhookEvent`, `WebhookProcessingStatus` enum, `WebhookResult` with `processed`/`ignored`/`failed` constructors; `WebhookHandler` abstract contract
|
||||
- `integrations/lib/src/channel_adapter.dart` — new `ChannelProduct`, `ChannelOrder`, `ChannelOrderItem`, `ChannelInventoryCount` neutral value objects; `ChannelAdapter` abstract contract with products/orders/inventory operations
|
||||
- `integrations/lib/integrations.dart` — replaced `Calculator` placeholder with barrel exports for all 7 new contract files
|
||||
- `integrations/test/integrations_test.dart` — replaced placeholder test with 38 tests covering all contracts and value objects
|
||||
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — `WooCommerceApiClient` now implements `ApiClient`; added `initialize()`, `isReady`, `execute<T>()`; `dispose()` also resets `_ready`
|
||||
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — `WooCommerceApiException` now extends `ApiException` instead of implementing `Exception` directly
|
||||
- `feature_wordpress/pubspec.yaml` — added `integrations: path: ../integrations` dependency
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — added `packages/integrations` to both `TESTABLE` and `ANALYZABLE` lists
|
||||
- tests: passed (38/38 integrations, 319/319 feature_wordpress — 357 total for affected packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found in integrations and feature_wordpress)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/data-layer-contracts
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `data/lib/src/local_cache.dart` — new `LocalCache<T>` abstract contract; `InMemoryCache<T>` implementation with TTL-based expiry, key enumeration, and dispose
|
||||
- `data/lib/src/sync_queue.dart` — new `SyncOperationStatus` enum, `SyncOperation` value object with `copyWith()`; `SyncFlushResult` with `success`/`failure` constructors; `SyncQueue` abstract contract; `InMemorySyncQueue` implementation with enqueue, process (success/failure tracking), remove, pruneCompleted, and length
|
||||
- `data/lib/src/conflict_resolver.dart` — new `ConflictResolution` enum, `ConflictResult<T>` with `useLocal`/`useRemote`/`merged`/`requiresReview` constructors; `ConflictResolver<T>` abstract contract; `LastWriteWinsResolver<T>`, `LocalAlwaysWinsResolver<T>`, `RemoteAlwaysWinsResolver<T>` concrete implementations
|
||||
- `data/lib/src/change_tracker.dart` — new `ChangeState` enum, `TrackedChange<T>` value object with `copyWith()`; `ChangeTracker<T>` abstract contract; `InMemoryChangeTracker<T>` implementation with trackCreated/trackModified/trackDeleted/markSynced/markSyncError/pendingChanges/hasPendingChanges/clear
|
||||
- `data/lib/src/data_mapper.dart` — new `DataMapper<TLocal, TRemote>` abstract contract with `fromRemote`/`toRemote`/`fromRemoteList`/`toRemoteList`; `DataMappingException` with field, message, rawValue
|
||||
- `data/lib/data.dart` — replaced `Calculator` placeholder with barrel exports for all 5 new contract files
|
||||
- `data/test/data_test.dart` — replaced placeholder test with 63 tests covering all contracts and implementations
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — added `packages/data` to both `TESTABLE` and `ANALYZABLE` lists
|
||||
- tests: passed (63/63 data)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/auth-foundation
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `auth/lib/src/domain/kc_user.dart` — `KcUser` value object with `id`, `displayName`, `email`, `role`; `KcUserRole` enum (`admin`, `operator`, `viewer`)
|
||||
- `auth/lib/src/domain/auth_state.dart` — `AuthState` sealed class hierarchy: `AuthStateUnauthenticated`, `AuthStateAuthenticated`, `AuthStateLoading`, `AuthStateError`
|
||||
- `auth/lib/src/domain/auth_exception.dart` — `AuthException` with `code` and `message`; named constructors `invalidCredentials`, `sessionExpired`, `networkError`, `unknown`
|
||||
- `auth/lib/src/domain/auth_service.dart` — `AuthService` abstract contract with `login()`, `logout()`, `currentUser`, `authStateStream`, `isAuthenticated`
|
||||
- `auth/lib/src/domain/credential_store.dart` — `CredentialKeys` constants; `CredentialStore` abstract contract with `save()`, `load()`, `delete()`, `clear()`
|
||||
- `auth/lib/src/data/in_memory_credential_store.dart` — `InMemoryCredentialStore` implementation
|
||||
- `auth/lib/src/data/fake_auth_service.dart` — `FakeAuthService` with configurable users, delay simulation, and stream broadcasting
|
||||
- `auth/lib/auth.dart` — barrel exports for all auth types
|
||||
- `auth/test/auth_test.dart` — 42 tests covering all domain models, contracts, and implementations
|
||||
- `core/lib/src/app/kc_app_environment.dart` — added `KcAppEnvironment.square` value
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — added `packages/auth` to `TESTABLE` and `ANALYZABLE` lists
|
||||
- tests: passed (42/42 auth, 21/21 core — all passing)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/wc-orders-integration
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/domain/order_status.dart` — added `onHold`, `refunded`, `failed` values
|
||||
- `feature_orders/lib/src/domain/order_item.dart` — added `productId`, `variationId`, `customizations` fields
|
||||
- `feature_orders/lib/src/domain/order.dart` — added `paymentMethod`, `source`, `customFields` fields
|
||||
- `feature_orders/lib/src/domain/orders_repository.dart` — added `getOrder(id)` to contract
|
||||
- `feature_orders/lib/src/domain/order_source.dart` — new `OrderSource` enum (`online`, `market`, `manual`)
|
||||
- `feature_orders/lib/src/data/fake_orders_repository.dart` — implemented `getOrder(id)` with null-safe lookup
|
||||
- `feature_orders/lib/src/data/woo_commerce_order_mapper.dart` — new `WooCommerceOrderMapper` mapping WooCommerce JSON ↔ `Order` domain model
|
||||
- `feature_orders/lib/src/data/woo_commerce_orders_repository.dart` — new `WooCommerceOrdersRepository` implementing full `OrdersRepository` contract via `WooCommerceApiClient`
|
||||
- `feature_orders/lib/feature_orders.dart` — expanded barrel exports for all new types
|
||||
- `feature_orders/pubspec.yaml` — added `feature_wordpress` dependency; added `http` dev dependency
|
||||
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `getOrders()`, `getOrder(id)`, `updateOrderStatus()`, `createOrder()`, `addOrderNote()` methods
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — exported `WooCommerceApiClient` and `WooCommerceApiException`
|
||||
- `feature_orders/test/woo_commerce_order_mapper_test.dart` — 25 mapper tests covering full JSON → domain mapping, status mapping, item mapping, customizations, edge cases
|
||||
- `feature_orders/test/woo_commerce_orders_repository_test.dart` — 23 repository and API client tests covering all CRUD operations, error handling, empty-note guard, and FakeOrdersRepository.getOrder
|
||||
- tests: passed (198/198 feature_orders, 24/24 kell_web, 26/26 kell_mobile — 248 total for affected packages; 847/847 total across all packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders, feature_wordpress, kell_web, kell_mobile)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/bulk-operator-workflows
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/data/wordpress_product_mapper.dart` — `toWooCommerceStatus()` now maps `PublishStatus.pendingReview` → `'pending'` instead of throwing `ArgumentError`; only `unpublished` throws
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_confirmBulkPublish()` and `_confirmBulkSubmitForReview()` confirmation dialogs; updated `_MultiSelectBar` with `onBulkPublish` and `onBulkSubmitForReview` callbacks; added "Publish" and "Submit for Review" `OutlinedButton.icon` buttons; refactored bar layout from `Row` to `Wrap` for responsive overflow
|
||||
- `feature_wordpress/test/wordpress_product_mapper_test.dart` — updated `pendingReview` test: now expects `'pending'` return value instead of `ArgumentError`
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 3 new bulk tests: publishes all selected products, submits all for review, handles mixed statuses to published
|
||||
- `feature_wordpress/test/widgets/product_publishing_page_test.dart` — added 5 new widget tests: multi-select bar shows Publish button, Publish confirmation dialog, Submit for Review confirmation dialog, cancel preserves selection, plural count in dialog
|
||||
- `feature_wordpress/test/wordpress_product_publishing_repository_test.dart` — removed `pendingReview` from unsupported-throws test; only `unpublished` asserted
|
||||
- tests: passed (319/319 feature_wordpress)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/wc-inventory-sync
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_inventory/lib/src/domain/inventory_sync_status.dart` — new `InventorySyncStatus` enum (`notSynced`, `synced`, `pending`, `conflict`, `error`)
|
||||
- `feature_inventory/lib/src/domain/inventory_item.dart` — added `wooCommerceProductId` (nullable int), `syncStatus`, `lastSyncedAt` fields; updated `copyWith()` to preserve/update all three
|
||||
- `feature_inventory/lib/src/domain/inventory_repository.dart` — added `getItemBySku(sku)` and `syncWithRemote(id, remoteQuantity, reason)` to contract
|
||||
- `feature_inventory/lib/src/data/fake_inventory_repository.dart` — updated seed data with `wooCommerceProductId`, `syncStatus`, `lastSyncedAt`; implemented `getItemBySku()` and `syncWithRemote()` with status auto-derivation and `lastSyncedAt` stamping
|
||||
- `feature_inventory/lib/src/application/inventory_sync_service.dart` — new `InventorySyncService` orchestrating pull-only or bidirectional sync; `syncAll()`, `syncItem(id)`, `checkDifference(id)`; `InventorySyncResult` value object; `StockDifference` value object
|
||||
- `feature_inventory/lib/src/data/woo_commerce_inventory_repository.dart` — new `WooCommerceInventoryRepository` implementing full `InventoryRepository` contract; `adjustQuantity()` and `setQuantity()` push to WooCommerce via `updateProductStock()`; `syncWithRemote()` pulls remote quantity and stamps `lastSyncedAt`; local-only items (no `wooCommerceProductId`) skip WooCommerce calls
|
||||
- `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for `InventorySyncService`, `WooCommerceInventoryRepository`, `InventorySyncStatus`
|
||||
- `feature_inventory/pubspec.yaml` — added `feature_wordpress: path: ../feature_wordpress` dependency; added `http: ^1.4.0` dev dependency
|
||||
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `getProductStock(productId)`, `updateProductStock(productId, quantity)`, `batchUpdateStock(updates)` methods
|
||||
- `feature_inventory/test/woo_commerce_inventory_sync_test.dart` — 57 new tests covering `InventorySyncStatus`, `InventoryItem` sync fields, `FakeInventoryRepository` (`getItemBySku`, `syncWithRemote`), `WooCommerceApiClient` stock methods, `WooCommerceInventoryRepository`, `InventorySyncService`, `StockDifference`, `InventorySyncResult`
|
||||
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubInventoryRepository` and `_FailingInventoryRepository` stubs to implement expanded `InventoryRepository` contract (`getItemBySku`, `syncWithRemote`)
|
||||
- tests: passed (132/132 feature_inventory, 24/24 kell_web, 319/319 feature_wordpress — all passing)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/wc-catalog-expansion
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/domain/product_category.dart` — new `ProductCategory` value object with `id`, `name`, `slug`, `parentId`, `count`; `copyWith()`, `==`/`hashCode`, `toString()`
|
||||
- `feature_wordpress/lib/src/domain/product_image.dart` — new `ProductImage` value object with `id`, `url`, `alt`, `position`; `copyWith()`, `==`/`hashCode`
|
||||
- `feature_wordpress/lib/src/domain/product_publishing_repository.dart` — added `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to contract
|
||||
- `feature_wordpress/lib/src/data/fake_product_publishing_repository.dart` — implemented all 8 new repository methods; added seed categories (Jewelry, Accessories, Home Decor, Kitchen Accessories); `createProduct()` resolves category name from id; `createCategory()` auto-generates slug from name; `updateCategory()` and `deleteCategory()` throw `StateError` for unknown ids
|
||||
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `createProduct()`, `deleteProduct()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()`, `setProductImages()` methods; `getCategories()` paginates with `per_page=100`
|
||||
- `feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart` — implemented all 8 new repository methods delegating to `WooCommerceApiClient`; maps JSON ↔ domain models
|
||||
- `feature_wordpress/lib/src/application/create_product.dart` — new `CreateProduct` use case
|
||||
- `feature_wordpress/lib/src/application/delete_product.dart` — new `DeleteProduct` use case
|
||||
- `feature_wordpress/lib/src/application/get_categories.dart` — new `GetCategories` use case
|
||||
- `feature_wordpress/lib/src/application/create_category.dart` — new `CreateCategory` use case
|
||||
- `feature_wordpress/lib/src/application/update_product_images.dart` — new `UpdateProductImages` use case
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — exported all new domain models and use cases
|
||||
- `feature_wordpress/test/wc_catalog_expansion_test.dart` — 69 new tests covering `ProductCategory`, `ProductImage`, `FakeProductPublishingRepository` (all new methods), `WooCommerceApiClient` (all new methods), `WordPressProductPublishingRepository` (all new methods), and all 5 use cases
|
||||
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubProductPublishingRepository` to implement expanded `ProductPublishingRepository` contract
|
||||
- `kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart` — updated `_FakeProductPublishingRepository` stub to implement expanded contract
|
||||
- tests: passed (388/388 feature_wordpress, 24/24 kell_web — all passing)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/wc-catalog-expansion-ui
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/presentation/widgets/create_product_dialog.dart` — new `CreateProductDialog` StatefulWidget with name (required), price (required), SKU, category dropdown, description, and initial-status `SegmentedButton` (Draft / Review); inline validation errors; `CreateProductDialogResult` value object; `showCreateProductDialog()` helper
|
||||
- `feature_wordpress/lib/src/presentation/category_management_page.dart` — new `CategoryManagementPage` with category list, add-category FAB, inline rename, delete with confirmation dialog, empty state, loading/error states; `CategoryManagementPage.route()` static helper
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `CreateProductResult` sealed class (`CreateProductSuccess`, `CreateProductFailure`); added `lastCreateProductResult`, `consumeCreateProductResult()`, `createNewProduct()` (calls `CreateProduct` use case, auto-selects new product, reloads); added `fetchCategories()` (calls `GetCategories` use case); `CreateProduct` and `GetCategories` use cases are optional named constructor params for backward compatibility
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added "New Product" `FilledButton.icon` in toolbar; wired `showCreateProductDialog()` → `controller.createNewProduct()`; added `showCreateProductSnackBar()` result listener; added "Manage Categories" `TextButton` in toolbar linking to `CategoryManagementPage`
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — exported `CreateProductDialog`, `CreateProductDialogResult`, `showCreateProductDialog`, `CategoryManagementPage`, `CreateProductResult`, `CreateProductSuccess`, `CreateProductFailure`
|
||||
- `kell_mobile/lib/pages/mobile_create_product_page.dart` — new `MobileCreateProductPage` full-screen form (name, price, SKU, category dropdown, description, initial status) with `AppBar` and "Create Product" action button; mirrors dialog fields in a scrollable page layout
|
||||
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — added "New Product" `FloatingActionButton` navigating to `MobileCreateProductPage`; wired result back to `controller.createNewProduct()`; added `showCreateProductSnackBar()` result listener
|
||||
- `feature_wordpress/test/widgets/create_product_dialog_test.dart` — 13 new widget tests: renders fields, Cancel returns null, name/price validation errors, valid submit returns result, SKU/description included, category dropdown shown/hidden, initial status defaults to draft, error-clears-on-type, standalone render
|
||||
- `feature_wordpress/test/widgets/category_management_page_test.dart` — 14 new widget tests: renders category list, empty state, add category dialog, rename inline, delete confirmation, loading state, error state, category count
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 7 new controller tests: `createNewProduct` success/auto-select/failure/consume/null-use-case, `fetchCategories` success/null-use-case
|
||||
- tests: passed (424/424 feature_wordpress — all passing)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-api-client
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-19
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `integrations/lib/src/square_api_client.dart` — new `SquareApiClient` implementing `ApiClient` contract; `sandbox()` and `production()` factory constructors; `initialize()`, `dispose()`, `isReady`, `execute<T>()` lifecycle; Catalog API (`listCatalogItems()` with cursor pagination, `getCatalogItem()`, `upsertCatalogItem()`, `deleteCatalogItem()`); Inventory API (`getInventoryCount()`, `batchChangeInventory()`); Orders API (`createOrder()`, `listOrders()`, `getOrder()`); Payments API (`createPayment()`, `getPayment()`); Bearer token + Square-Version headers on all requests
|
||||
- `integrations/lib/src/square_api_exception.dart` — new `SquareApiException` extending `ApiException` with `statusCode`, `message`, `body`; `toString()` includes all fields
|
||||
- `integrations/lib/integrations.dart` — expanded barrel exports for `SquareApiClient` and `SquareApiException`
|
||||
- `integrations/test/square_api_client_test.dart` — 50 new tests covering `SquareApiException` (5), `SquareApiClient` construction (2), `ApiClient` contract (5), Catalog API (12), Inventory API (7), Orders API (9), Payments API (8), auth headers (2)
|
||||
- `integrations/pubspec.yaml` — removed duplicate `http` dev dependency (was also in normal dependencies)
|
||||
- `core/lib/src/composition/kc_app_environment.dart` — added `multi` value to `KcAppEnvironment` enum (joining existing `fake`, `wp`, `square`)
|
||||
- `core/lib/src/composition/kc_app_config.dart` — added `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define key constants
|
||||
- `core/test/core_test.dart` — added 3 new tests: `KcAppEnvironment.multi` exists, `KC_SQUARE_ACCESS_TOKEN` constant, `KC_SQUARE_LOCATION_ID` constant — 24 total core tests
|
||||
- tests: passed (88/88 integrations, 24/24 kell_web, 26/26 kell_mobile, 424/424 feature_wordpress, 132/132 feature_inventory, 198/198 feature_orders, 41/41 design_system, 24/24 core — 957 total)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages/apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync
|
||||
|
||||
- status: in progress on branch (commit `a6f888c`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10B slice)
|
||||
- files changed:
|
||||
- `integrations/lib/src/square_product_mapper.dart` — new `SquareProductMapper`; `fromSquareCatalogObject()` maps Square ITEM CatalogObject JSON → `ChannelProduct` (cents→dollars, `is_deleted` → `archived` status, first-variation SKU/price extraction); `fromSquareCatalogList()` batch variant skipping non-ITEM entries; `toSquareCatalogObject()` maps `ChannelProduct` → Square ITEM JSON (dollars→cents rounding, default variation with `FIXED_PRICING`, `location_overrides` with `track_inventory`, optional description/SKU omitted when null)
|
||||
- `integrations/lib/src/square_inventory_mapper.dart` — new `SquareInventoryMapper`; `fromSquareInventoryCount()` maps Square InventoryCount JSON → `ChannelInventoryCount` (IN_STOCK only, string quantity parsed safely); `fromSquareInventoryCountList()` batch variant; `toSquareBatchChangeEntry()` maps `ChannelInventoryCount` → Square PHYSICAL_COUNT batch-change entry (quantity as string, RFC 3339 timestamp); `toSquareBatchChangeEntries()` batch variant
|
||||
- `integrations/lib/integrations.dart` — added `square_inventory_mapper.dart` and `square_product_mapper.dart` to barrel exports
|
||||
- `integrations/test/square_catalog_sync_test.dart` — 35 new tests: `SquareProductMapper` (20: fromSquareCatalogObject ×9, fromSquareCatalogList ×3, toSquareCatalogObject ×6) and `SquareInventoryMapper` (15: fromSquareInventoryCount ×8, fromSquareInventoryCountList ×3, toSquareBatchChangeEntry ×3, toSquareBatchChangeEntries ×2)
|
||||
- `feature_wordpress/lib/src/domain/product_id_mapping.dart` — new `ProductIdMapping` value object with `localId`, `wooCommerceId`, `squareCatalogObjectId`, `squareVariationId`, `lastUpdated`; `copyWith()`, `==`/`hashCode`, `toString()`
|
||||
- `feature_wordpress/lib/src/domain/product_id_mapping_repository.dart` — new `ProductIdMappingRepository` abstract contract: `getAllMappings()`, `getMappingByLocalId()`, `getMappingByWooCommerceId()`, `getMappingBySquareId()`, `saveMapping()` (upsert), `deleteMapping()` (no-op safe)
|
||||
- `feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart` — new `FakeProductIdMappingRepository` in-memory implementation; seeded with 6 mappings matching fake catalog (products 1–3 fully synced with Square IDs, products 4–5 WooCommerce-only, product 6 draft/unsynced); 50ms simulated async delay on all operations; `getAllMappings()` returns `List.unmodifiable`
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `fake_product_id_mapping_repository.dart`, `product_id_mapping.dart`, `product_id_mapping_repository.dart` to barrel exports
|
||||
- `feature_wordpress/test/product_id_mapping_test.dart` — 23 new tests: `ProductIdMapping` (7: fields, null defaults, copyWith, equality, hashCode, toString) and `FakeProductIdMappingRepository` (16: getAllMappings, getMappingByLocalId, getMappingByWooCommerceId, getMappingBySquareId, seed state, saveMapping insert/replace/count, deleteMapping remove/count/no-op, unmodifiable list)
|
||||
- tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new)
|
||||
- analyze: clean (no new issues introduced)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service
|
||||
|
||||
- status: in progress on branch — ready for merge
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10D slice)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — new `SquareCatalogSyncService` application-layer service; `SyncError` value object with `itemId`, `message`, `==`/`hashCode`/`toString()`; `SquareSyncResult` value object with `catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`; `InventorySyncAdapter` abstract contract with `syncItem(itemId, remoteQuantity)`; `syncCatalog()` fetches all products via `ProductPublishingRepository.getProductDrafts()`, looks up or creates `ProductIdMapping` per product via `ProductIdMappingRepository`, captures per-item failures without aborting; `syncInventory()` iterates all mappings with a `squareVariationId`, calls `InventorySyncAdapter.syncItem()` for each, captures per-item failures; `syncAll()` runs `syncCatalog()` then `syncInventory()` and merges results; no-op when no adapter provided
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_sync_service.dart` to barrel exports (Application section)
|
||||
- `feature_wordpress/test/square_catalog_sync_service_test.dart` — 31 new tests: `SyncError` (3: fields, equality, toString), `SquareSyncResult` (7: defaults, totalProcessed, allSucceeded×3, merge, toString), `syncCatalog` (9: empty, single, id match, multiple, existing-not-duplicated, fetch-failure, saveMapping-failure, partial-failure, SyncError-details), `syncInventory` (7: no-adapter, no-variationId-skipped, synced, false-return, throw-continues, getAllMappings-failure, empty-mappings), `syncAll` (5: merges, pre-seeded-variation-IDs, totalProcessed, catalog-failure-no-prevent-inventory, no-adapter)
|
||||
- tests: passed (506/506 feature_wordpress ← +31 new, 167/167 feature_inventory — all passing)
|
||||
- analyze: clean (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### chore/audit-state-sync
|
||||
|
||||
- status: complete (documentation-only — no code changes)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete — full repository audit conducted against all 11 packages/apps
|
||||
- implementation: complete
|
||||
- audit reference: `PROJECT_STATE_AUDIT.md` — Kell Creations Operations Platform Comprehensive Code Audit
|
||||
- files changed:
|
||||
- `PROJECT_STATE_AUDIT.md` — new comprehensive audit document; covers monorepo architecture, built vs. remaining matrix for all 13 packages + 2 apps, technical debt assessment (dead code, error handling gaps, hardcoded values, test coverage, architectural antipatterns), and actionable engineering backlog (Phase 1/2/3 priorities)
|
||||
- `docs/development/master_development_brief.md` — updated validation state (core 24/24, feature_wordpress 506/506 post-10D, total 1,015/1,015); updated next recommended branch to include P1.1–P1.5 scope; added "Comprehensive State Audit (2026-07-25)" section with platform scorecard, Phase 1/2/3 backlog tables, and key technical debt findings; updated Appendix A (feature maturity matrix — all test counts, completeness percentages, kell_web/kell_mobile rows added); updated Appendix B (integration landscape — all Square repos now marked complete, WC repos status corrected); updated Appendix C (Square keys moved from "Planned" to "Active"; KC_ENV values table updated with current status)
|
||||
- `docs/development/build_execution_tracker.md` — updated current status section (audit completion noted, pending merge flagged, next branch scope documented); added this slice entry
|
||||
- tests: n/a (documentation-only change)
|
||||
- analyze: n/a (documentation-only change)
|
||||
- brief updated: yes
|
||||
- audit findings summary:
|
||||
- 1,015/1,015 tests passing across all 11 packages/apps
|
||||
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||
- CRITICAL: `AppServices`/`MobileAppServices` have no `square()` or `multi()` factory — `KC_ENV=square` silently falls to fake mode
|
||||
- HIGH: `SquareCatalogSyncService` ID conflation bug; `RetryPolicy`/`RateLimiter` not wired; `auth` package not imported by any app
|
||||
- Phase 1 (5 items) → target `feat/square-catalog-sync-service-controller` (Stage 10E)
|
||||
- Phase 2 (10 items) → Stages 10C, 11, 12, 13, 14, 15
|
||||
- Phase 3 (12 items) → Stages 16A/B + standalone hardening tasks
|
||||
|
||||
### feat/square-catalog-sync-repositories
|
||||
|
||||
- status: in progress on branch — ready for merge
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10C slice)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/data/square_catalog_repository.dart` — new `SquareCatalogRepository` implementing `ProductPublishingRepository`; backed by `SquareApiClient`; `getProductDrafts()` calls `listCatalogItems()` and maps via `SquareProductMapper`, populating an in-memory cache; `publishDraft()` returns cached draft with `published` status (no-op — Square items are always live); `updateProductStatus()`, `updateProductPrice()`, `updateProductName()`, `updateProductDescription()`, `updateProductCategory()` each mutate the cached `ProductDraft`, call `upsertCatalogItem()` with the updated Square JSON, and return the refreshed draft; `createProduct()` calls `upsertCatalogItem()` with a `#NEW_` idempotency key, maps the response, and adds to cache; `deleteProduct()` calls `deleteCatalogItem()` and removes from cache; `updateProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()` throw `UnsupportedError` (Square manages these separately)
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_repository.dart` to barrel exports
|
||||
- `feature_inventory/lib/src/domain/inventory_item.dart` — added `squareVariationId` (nullable String) field; updated `copyWith()` to preserve/update it
|
||||
- `feature_inventory/lib/src/data/square_inventory_repository.dart` — new `SquareInventoryRepository` implementing `InventoryRepository`; backed by `SquareApiClient` + `initialItems` seed list; `getInventoryItems()` returns `List.unmodifiable`; `getItemBySku()` linear scan; `adjustQuantity()` validates non-negative result, pushes `batchChangeInventory()` when `squareVariationId` is set (stamps `synced`/`lastSyncedAt`), otherwise marks `notSynced`; `setQuantity()` validates non-negative, same Square push pattern; `syncWithRemote()` validates non-negative, calls `getInventoryCount()` when `squareVariationId` is set and uses live count (falls back to `remoteQuantity` on empty response), stamps `synced`/`lastSyncedAt`; `createItem()` appends to local store; `updateItem()` replaces by id and stamps `lastUpdated`; `updateItemImage()` updates `imageUrl` by id; all write failures return `InventoryAdjustmentResult.failure()` with descriptive messages; status auto-derived from quantity vs `reorderPoint`
|
||||
- `feature_inventory/lib/feature_inventory.dart` — added `square_inventory_repository.dart` to barrel exports
|
||||
- `feature_inventory/pubspec.yaml` — added `integrations: path: ../integrations` dependency
|
||||
- `feature_wordpress/test/square_catalog_repository_test.dart` — 30 new tests: `getProductDrafts` (5: empty, single mapping, is_deleted status, multi+skip non-ITEM, cache population), `publishDraft` (2: success, StateError), `updateProductStatus` (2: upserts, StateError), `updateProductPrice` (2: upserts, StateError), `updateProductName` (1), `updateProductDescription` (1), `updateProductCategory` (1), `createProduct` (4: maps result, draft status, StateError on bad type, adds to cache), `deleteProduct` (1: removes from cache), unsupported operations (6: images/categories all throw UnsupportedError), status mapping (2), SquareApiException propagation (1)
|
||||
- `feature_inventory/test/square_inventory_repository_test.dart` — 37 new tests: `getInventoryItems` (3: all items, empty, unmodifiable), `getItemBySku` (2), `adjustQuantity` (8: local-only, Square push, not-found, below-zero, API failure, inStock/lowStock/outOfStock status derivation), `setQuantity` (5: local-only, Square push, negative, not-found, API failure), `syncWithRemote` (6: local-only, Square pull, empty-count fallback, negative, not-found, API failure), `createItem` (2), `updateItem` (3: updates, StateError, stamps lastUpdated), `updateItemImage` (2), `InventoryItem.squareVariationId` (4: default null, constructor, copyWith preserve, copyWith update)
|
||||
- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new)
|
||||
- analyze: clean
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-sync-ui
|
||||
|
||||
- status: ready for merge (branch `feat/square-sync-ui`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10F — Square sync UI surface)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart` — new `SquareSyncStatusPanel` `StatefulWidget`; listens to `SquareSyncController`; shows status chip (`Never synced` / `OK` / `Partial` / `Error`), last-synced timestamp, `Syncing…` progress indicator during sync, `Sync Now` `FilledButton` (disabled while syncing), and last-sync-result section with Catalog/Inventory row counts when `lastResult` is non-null
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_status_panel.dart` to barrel exports (Presentation section)
|
||||
- `kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart` — new `IntegrationsPage`; shows `SquareSyncStatusPanel` when `squareSyncController` is non-null; shows placeholder card with hub icon and "No integrations configured" message otherwise; "Square" section label when controller is present
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart` — new `MobileIntegrationsPage`; mirrors `IntegrationsPage` layout for mobile; `SafeArea` wrapper
|
||||
- `kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart` — added `AppRoute.integrations` case routing to `IntegrationsPage(squareSyncController: services.squareSyncController)`
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart` — More tab (case 4) switched from `IntegrationsPlaceholderPage` to `MobileIntegrationsPage(squareSyncController: services.squareSyncController)`
|
||||
- `feature_wordpress/test/square_sync_status_panel_test.dart` — 8 new widget tests: renders title, shows Never synced chip, shows Last synced: Never, Sync Now button enabled, shows OK chip after sync, shows last sync result section, rebuilds on notify, unregisters listener on dispose; uses `tester.pump(Duration)` to advance fake time through `Future.delayed` timers
|
||||
- `kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart` — 4 new widget tests: placeholder when controller null, shows `SquareSyncStatusPanel` when controller provided, shows Square section label, placeholder shows hub icon
|
||||
- `kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart` — 4 new widget tests: mirroring kell_web integrations page tests for mobile
|
||||
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — expanded to 28 tests (added `squareSyncController` non-null assertion for `square()` factory, `multi()` factory tests, full `KcBootstrap` credential validation tests)
|
||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — expanded to 28 tests (mirrored kell_web composition tests)
|
||||
- tests: passed (545/545 feature_wordpress ← +8 new; 52/52 kell_web ← +5 new; 54/54 kell_mobile ← +5 new — 651 total across tested packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service-controller
|
||||
|
||||
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10E — all P1.1–P1.5 items)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/square_sync_controller.dart` — new `SquareSyncController` `ChangeNotifier`; `SyncPhase` enum (`idle`, `syncing`, `done`, `error`); `syncAll()` delegates to `SquareCatalogSyncService`, sets `phase`/`lastResult`/`lastError`, guards against concurrent calls; `reset()` returns to idle; `isLoading` getter; disposal guard prevents post-dispose notifications
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_controller.dart` to barrel exports (Application section)
|
||||
- `kell_creations_apps/apps/kell_web/lib/composition/app_services.dart` — added `square()` factory (P1.1): `SquareCatalogRepository` as `productPublishingRepository`, `SquareInventoryRepository` as `inventoryRepository`, `SquareSyncController` wired to `SquareCatalogSyncService`; added `multi()` factory: `SquareInventoryRepository` + `WordPressProductPublishingRepository`; updated `KcBootstrap` to validate Square credentials (P1.5) and fall back to fake with warning; added `squareSyncController` field to `AppServices`
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart` — mirrored `square()` and `multi()` factories; added `squareSyncController` field; same `KcBootstrap` Square credential validation
|
||||
- `feature_wordpress/lib/src/domain/product_draft.dart` — added `squareCatalogObjectId` (nullable String) field to `ProductDraft`; updated `copyWith()` to preserve/update it; updated `==`/`hashCode`/`toString()`
|
||||
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — fixed ID conflation bug (P1.2): `syncCatalog()` now reads `draft.squareCatalogObjectId` to look up existing mapping instead of using `draft.id`; mapping lookup uses `getMappingBySquareId()` when Square ID is present, falls back to `getMappingByLocalId()` for unsynced items; prevents duplicate mapping creation for already-synced products
|
||||
- `feature_wordpress/test/square_sync_controller_test.dart` — 18 new tests: `SyncPhase` enum (1), `SquareSyncController` construction (2), `syncAll` success (3), `syncAll` error (2), concurrent guard (2), `reset()` (2), `isLoading` (2), disposal guard (2), `lastResult`/`lastError` lifecycle (2)
|
||||
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — added `square()` factory tests (3: creates instance, `SquareCatalogRepository`, `SquareInventoryRepository`), `multi()` factory tests (2), `KcBootstrap` Square credential validation tests (3: selects square with credentials, selects multi with all credentials, falls back to fake without credentials) — 23 total kell_web composition tests
|
||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
|
||||
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
|
||||
- brief updated: yes
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
BIN
filetree.txt
BIN
filetree.txt
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class FinancePlaceholderPage extends StatelessWidget {
|
||||
const FinancePlaceholderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('Finance page coming soon'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class IntegrationsPlaceholderPage extends StatelessWidget {
|
||||
const IntegrationsPlaceholderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('Integrations page coming soon'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -68,41 +43,6 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
|
|||
@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 +51,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 +78,7 @@ void main() {
|
|||
|
||||
test('load() sets isLoading then populates summary', () async {
|
||||
final inventoryItems = [
|
||||
_inv(
|
||||
const InventoryItem(
|
||||
id: '1',
|
||||
sku: 'A',
|
||||
name: 'A',
|
||||
|
|
@ -243,7 +86,7 @@ void main() {
|
|||
unitPrice: 5.0,
|
||||
status: InventoryStatus.inStock,
|
||||
),
|
||||
_inv(
|
||||
const InventoryItem(
|
||||
id: '2',
|
||||
sku: 'B',
|
||||
name: 'B',
|
||||
|
|
@ -295,7 +138,7 @@ void main() {
|
|||
|
||||
test('load() aggregates data from all three repositories', () async {
|
||||
final inventoryItems = [
|
||||
_inv(
|
||||
const InventoryItem(
|
||||
id: '1',
|
||||
sku: 'A',
|
||||
name: 'A',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)' : ''}';
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
sdk: ^3.11.4
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/// Shared [InheritedWidget] that exposes [KcAppServices] and [KcAppConfig]
|
||||
/// to the widget tree.
|
||||
///
|
||||
/// Both `kell_web` and `kell_mobile` use this widget at the root of their
|
||||
/// widget tree so that any descendant can retrieve services and configuration.
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'kc_app_config.dart';
|
||||
import 'kc_app_services.dart';
|
||||
|
||||
/// An [InheritedWidget] that exposes [KcAppServices] and [KcAppConfig] to the
|
||||
/// widget tree.
|
||||
///
|
||||
/// Wrap the app (or a subtree) with [KcAppScope] and retrieve the services
|
||||
/// anywhere below via [KcAppScope.of<T>(context)].
|
||||
///
|
||||
/// The type parameter [T] allows each app target to retrieve its concrete
|
||||
/// services subclass without casting:
|
||||
///
|
||||
/// ```dart
|
||||
/// // In kell_web:
|
||||
/// final services = KcAppScope.of<WebAppServices>(context);
|
||||
/// services.inventoryRepository; // ← strongly typed
|
||||
/// ```
|
||||
class KcAppScope<T extends KcAppServices> extends InheritedWidget {
|
||||
/// The concrete services for this app target.
|
||||
final T services;
|
||||
|
||||
/// The effective runtime configuration.
|
||||
final KcAppConfig config;
|
||||
|
||||
const KcAppScope({super.key, required this.services, required this.config, required super.child});
|
||||
|
||||
/// Returns the nearest [T] (concrete services) from the widget tree.
|
||||
///
|
||||
/// Throws if no [KcAppScope] ancestor is found.
|
||||
static T of<T extends KcAppServices>(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<KcAppScope<T>>();
|
||||
assert(scope != null, 'No KcAppScope<$T> found in the widget tree');
|
||||
return scope!.services;
|
||||
}
|
||||
|
||||
/// Returns the nearest [KcAppConfig] from the widget tree.
|
||||
///
|
||||
/// This is a convenience accessor that works regardless of the concrete
|
||||
/// services type — it looks for any [KcAppScope] with any type parameter.
|
||||
///
|
||||
/// For apps that know their concrete type, prefer using the typed overload.
|
||||
static KcAppConfig configOf<T extends KcAppServices>(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<KcAppScope<T>>();
|
||||
assert(scope != null, 'No KcAppScope<$T> found in the widget tree');
|
||||
return scope!.config;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(KcAppScope<T> oldWidget) =>
|
||||
services != oldWidget.services || config != oldWidget.config;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue