Compare commits
30 Commits
feat/descr
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
f056d5f0b5 | |
|
|
effaadc84b | |
|
|
2af9a8b6cb | |
|
|
4f72bdb486 | |
|
|
f30ad24d8a | |
|
|
71abe9df7f | |
|
|
6a6323ef57 | |
|
|
b00072474b | |
|
|
0a0abc2c3d | |
|
|
9eafc68fec | |
|
|
6f10efc88d | |
|
|
8facefdff1 | |
|
|
bee610ca2c | |
|
|
02090cde6a | |
|
|
eaf3e70d30 | |
|
|
738336d953 | |
|
|
a0aea373c2 | |
|
|
f3fbbca06d | |
|
|
dfe7ae1811 | |
|
|
49a3702cec | |
|
|
b81016df28 | |
|
|
02cc75c655 | |
|
|
73b4a49939 | |
|
|
7acff83bf4 | |
|
|
cf0889d4a9 | |
|
|
8e7e4cbc69 | |
|
|
b69edd3e4a | |
|
|
24671f5f59 | |
|
|
3e233b0df6 | |
|
|
cebac4c32f |
|
|
@ -0,0 +1,77 @@
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
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,3 +4,6 @@ __pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Flutter test coverage output
|
||||||
|
coverage/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"MD013": false,
|
||||||
|
"MD024": {
|
||||||
|
"siblings_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,12 @@ 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.
|
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
|
## Diagram
|
||||||
|
|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ The source for this diagram is maintained as architecture code in:
|
||||||
|
|
||||||
## Diagram
|
## Diagram
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Included Data Services and Stores
|
## Included Data Services and Stores
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ 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.
|
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
|
## Diagram
|
||||||
|
|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,84 @@
|
||||||
# ADR Index
|
# ADR Index
|
||||||
|
|
||||||
Architecture Decision Records will be maintained here.
|
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,3 +1,120 @@
|
||||||
# Architecture Overview
|
# Architecture Overview
|
||||||
|
|
||||||
This section documents the Kell Creations platform using C4 modeling, PlantUML, and supporting technical documentation.
|
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,6 +8,12 @@ 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.
|
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
|
## Diagram
|
||||||
|
|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ Use this page to answer questions such as:
|
||||||
## 1. Enterprise Architecture Traceability
|
## 1. Enterprise Architecture Traceability
|
||||||
|
|
||||||
| Enterprise View | File | Supports Domains |
|
| Enterprise View | File | Supports Domains |
|
||||||
|---|---|---|
|
| --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
|
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
|
||||||
| Platform Context | `docs/architecture/context/platform.md` | All domains |
|
| Platform Context | `docs/architecture/context/platform.md` | All domains |
|
||||||
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
|
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
|
||||||
|
|
@ -42,7 +42,7 @@ Use this page to answer questions such as:
|
||||||
## 2. Application Component Traceability
|
## 2. Application Component Traceability
|
||||||
|
|
||||||
| Application Domain | Component View | File | Related Enterprise Views |
|
| Application Domain | Component View | File | Related Enterprise Views |
|
||||||
|---|---|---|---|
|
| ------------------------- | ------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------ |
|
||||||
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
|
| 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 |
|
| 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 |
|
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
|
||||||
|
|
@ -54,7 +54,7 @@ Use this page to answer questions such as:
|
||||||
## 3. Dynamic Workflow Traceability
|
## 3. Dynamic Workflow Traceability
|
||||||
|
|
||||||
| Workflow | File | Primary Domains | Related Component Views |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
|
||||||
|
|
@ -66,7 +66,7 @@ Use this page to answer questions such as:
|
||||||
## 4. Governance Document Traceability
|
## 4. Governance Document Traceability
|
||||||
|
|
||||||
| Governance Document | File | Governs Domains | Related Workflows / Views |
|
| 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 |
|
| 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 |
|
| 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,6 +75,7 @@ Use this page to answer questions such as:
|
||||||
## 5. Domain-to-Artifact Mapping
|
## 5. Domain-to-Artifact Mapping
|
||||||
|
|
||||||
### Inventory
|
### Inventory
|
||||||
|
|
||||||
- Component View:
|
- Component View:
|
||||||
- `docs/architecture/components/inventory.md`
|
- `docs/architecture/components/inventory.md`
|
||||||
- Dynamic Workflows:
|
- Dynamic Workflows:
|
||||||
|
|
@ -88,6 +89,7 @@ Use this page to answer questions such as:
|
||||||
- Integration & Orchestration
|
- Integration & Orchestration
|
||||||
|
|
||||||
### Craft Manufacturing / MRP
|
### Craft Manufacturing / MRP
|
||||||
|
|
||||||
- Component View:
|
- Component View:
|
||||||
- `docs/architecture/components/mrp.md`
|
- `docs/architecture/components/mrp.md`
|
||||||
- Dynamic Workflows:
|
- Dynamic Workflows:
|
||||||
|
|
@ -101,6 +103,7 @@ Use this page to answer questions such as:
|
||||||
- Integration & Orchestration
|
- Integration & Orchestration
|
||||||
|
|
||||||
### WordPress Management
|
### WordPress Management
|
||||||
|
|
||||||
- Component View:
|
- Component View:
|
||||||
- `docs/architecture/components/wordpress-management.md`
|
- `docs/architecture/components/wordpress-management.md`
|
||||||
- Dynamic Workflows:
|
- Dynamic Workflows:
|
||||||
|
|
@ -115,6 +118,7 @@ Use this page to answer questions such as:
|
||||||
- Deployment
|
- Deployment
|
||||||
|
|
||||||
### Social Media Management
|
### Social Media Management
|
||||||
|
|
||||||
- Component View:
|
- Component View:
|
||||||
- `docs/architecture/components/social-media-management.md`
|
- `docs/architecture/components/social-media-management.md`
|
||||||
- Dynamic Workflows:
|
- Dynamic Workflows:
|
||||||
|
|
@ -127,6 +131,7 @@ Use this page to answer questions such as:
|
||||||
- Audit & Compliance
|
- Audit & Compliance
|
||||||
|
|
||||||
### Financial Analysis
|
### Financial Analysis
|
||||||
|
|
||||||
- Component View:
|
- Component View:
|
||||||
- `docs/architecture/components/financial-analysis.md`
|
- `docs/architecture/components/financial-analysis.md`
|
||||||
- Dynamic Workflows:
|
- Dynamic Workflows:
|
||||||
|
|
@ -138,6 +143,7 @@ Use this page to answer questions such as:
|
||||||
- Audit & Compliance
|
- Audit & Compliance
|
||||||
|
|
||||||
### Governance / Policy Repository
|
### Governance / Policy Repository
|
||||||
|
|
||||||
- Governance Documents:
|
- Governance Documents:
|
||||||
- `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md`
|
- `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md`
|
||||||
- `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md`
|
- `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md`
|
||||||
|
|
@ -155,20 +161,28 @@ Use this page to answer questions such as:
|
||||||
|
|
||||||
The following areas are strong candidates for future documentation:
|
The following areas are strong candidates for future documentation:
|
||||||
|
|
||||||
|
!!! info "Last analyzed: 2026-05-22"
|
||||||
|
|
||||||
### Additional component views
|
### Additional component views
|
||||||
|
|
||||||
- Policy Repository internal components
|
- Policy Repository internal components
|
||||||
- Notification Service internal components
|
- Notification Service internal components
|
||||||
- API Orchestrator internal components
|
- API Orchestrator internal components
|
||||||
- Shared Data Repository internal components
|
- Shared Data Repository internal components
|
||||||
|
- Documentation Platform internal components (MkDocs, PlantUML server, Forgejo runners)
|
||||||
|
|
||||||
### Additional dynamic workflows
|
### Additional dynamic workflows
|
||||||
|
|
||||||
- Financial close / reporting workflow
|
- Financial close / reporting workflow
|
||||||
- Website change management workflow
|
- Website change management workflow
|
||||||
- Exception handling workflow
|
- Exception handling workflow
|
||||||
- Marketing campaign analytics workflow
|
- Marketing campaign analytics workflow
|
||||||
- Product image / media synchronization workflow
|
- Product image / media synchronization workflow
|
||||||
|
- Local development setup workflow
|
||||||
|
- Incident response workflow
|
||||||
|
|
||||||
### Additional governance documents
|
### Additional governance documents
|
||||||
|
|
||||||
- Access Control Policy
|
- Access Control Policy
|
||||||
- Social Media Publishing Standard
|
- Social Media Publishing Standard
|
||||||
- Product Publishing Procedure
|
- Product Publishing Procedure
|
||||||
|
|
@ -178,6 +192,29 @@ The following areas are strong candidates for future documentation:
|
||||||
- Website Change Management Procedure
|
- Website Change Management Procedure
|
||||||
- Exception Management Standard
|
- 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
|
## 7. Maintenance Rules
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,73 @@
|
||||||
# Brand Standards
|
# Brand Standards
|
||||||
|
|
||||||
This section maps the Kell Creations brand guide into reusable documentation and design 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).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Build Execution Tracker
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
- main baseline updated through: test-coverage-visibility (Stage 4D complete — Stage 4 complete)
|
||||||
|
- main baseline commit: merge of `feat/test-coverage-visibility` (2026-05-22)
|
||||||
|
- next branch: feat/android-app-shell (Stage 5A)
|
||||||
|
- current stage: Stage 5 — Android application foundation
|
||||||
|
|
||||||
|
## Slice tracker
|
||||||
|
|
||||||
|
### feat/description-only-edit
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- commit: `cebac4c`
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- tests: passed (212/212)
|
||||||
|
- analyze: passed
|
||||||
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/category-only-edit
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- commit: `8e7e4cb`
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- tests: passed (223/223 feature_wordpress, 5/5 kell_web dashboard)
|
||||||
|
- analyze: passed (dart analyze — no issues found)
|
||||||
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/post-write-consistency
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- commit: `7acff83`
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- files changed:
|
||||||
|
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `_refreshSelection()` helper; wired into `load()` and all write methods to preserve/refresh `selectedDraft` by id after reload
|
||||||
|
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 11 post-write consistency tests covering selection preservation, field refresh (status/price/name/description/category), lastModified, filter/search/sort persistence after writes, item repositioning under active sort, and filter-exit auto-reselection
|
||||||
|
- tests: passed (234/234 feature_wordpress)
|
||||||
|
- analyze: passed (dart analyze — no issues found)
|
||||||
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/publishing-ux-hardening
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- commit: `b81016d`
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- files changed:
|
||||||
|
- `feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart` — added inline validation error state (`_nameError`, `_priceError`, `_descriptionError`, `_categoryError`); error messages shown below text fields on empty/invalid submit; errors cleared on cancel; added `onCategoryChanged` callback; text fields and save buttons disabled during `isUpdating`; added tooltips (`Save price`, `Save category`, `Cancel`) for consistency
|
||||||
|
- `feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart` — failure SnackBar now appends `errorMessage` detail to the failure text for operator visibility
|
||||||
|
- `feature_wordpress/test/widgets/product_preview_panel_test.dart` — added `onCategoryChanged` to test helper; added 13 new tests: inline validation errors (name, price, description, category), error-clears-on-cancel, valid-input-clears-error, and text-field-disabled-during-isUpdating for all four edit fields
|
||||||
|
- `feature_wordpress/test/widgets/status_action_snack_bar_test.dart` — updated 2 failure SnackBar assertions to match new wording that includes error detail
|
||||||
|
- 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
|
||||||
|
|
@ -45,14 +45,23 @@ Rules:
|
||||||
|
|
||||||
### Platform structure
|
### Platform structure
|
||||||
|
|
||||||
- `apps/kell_web` exists.
|
- `apps/kell_web` exists — active, wired to shared packages.
|
||||||
|
- `apps/kell_mobile` exists — scaffolded as default Flutter template, **not yet integrated** with shared packages.
|
||||||
- Shared packages include:
|
- Shared packages include:
|
||||||
- `core`
|
- `core` — shared domain/application abstractions and cross-platform composition pattern (`KcAppConfig`, `KcAppServices`, `KcBootstrap`, `KcAppScope`)
|
||||||
- `design_system`
|
- `design_system` — theme (`KcColors`, `KcSpacing`, `KcTheme`), typography (`KcTypography`), layout (`KcBreakpoints`), and 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`)
|
||||||
- `feature_inventory`
|
- `feature_inventory` — domain, application, data (fake), and presentation layers
|
||||||
- `feature_wordpress`
|
- `feature_wordpress` — domain, application, data (fake + WooCommerce), and presentation layers (most mature feature)
|
||||||
- `feature_orders`
|
- `feature_orders` — domain, application, data (fake), and presentation layers
|
||||||
- `feature_policy`
|
- `feature_policy` — domain, application, data (fake), and presentation layers
|
||||||
|
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
||||||
|
- `auth` — authentication/authorization (stub only)
|
||||||
|
- `data` — shared data layer (stub only)
|
||||||
|
- `integrations` — shared integration abstractions (stub only)
|
||||||
|
- `feature_finance` — financial analysis feature (stub only)
|
||||||
|
- `feature_mrp` — craft manufacturing/MRP feature (stub only)
|
||||||
|
- `feature_social` — social media management feature (stub only)
|
||||||
|
- `tools/` directory contains CI helper scripts (`run_all_tests.sh`, `collect_coverage.sh`, `README.md`).
|
||||||
- App shell, routing, dashboard, reusable shell widgets, `AppServices`, and `AppScope` are implemented.
|
- App shell, routing, dashboard, reusable shell widgets, `AppServices`, and `AppScope` are implemented.
|
||||||
- Dashboard uses app-composed repository data.
|
- Dashboard uses app-composed repository data.
|
||||||
- Vertical slices exist for Inventory, Products/Publishing, Orders, and Policy/Governance.
|
- Vertical slices exist for Inventory, Products/Publishing, Orders, and Policy/Governance.
|
||||||
|
|
@ -77,20 +86,54 @@ Rules:
|
||||||
- Price-only product edit landed.
|
- Price-only product edit landed.
|
||||||
- Search/filter/sort refinement landed.
|
- Search/filter/sort refinement landed.
|
||||||
- Name-only product edit landed.
|
- Name-only product edit landed.
|
||||||
|
- ✅ Description-only product edit landed (Stage 1A complete — merged `feat/description-only-edit` → `main` at `cebac4c`, 2026-04-11).
|
||||||
|
- ✅ Category-only product edit landed (Stage 1B complete — merged `feat/category-only-edit` → `main` at `8e7e4cb`, 2026-04-11). Stage 1 complete.
|
||||||
|
- ✅ Post-write consistency hardening landed (Stage 2A complete — merged `feat/post-write-consistency` → `main` at `7acff83`, 2026-04-11).
|
||||||
|
- ✅ Publishing workflow UX hardening landed (Stage 2B complete — merged `feat/publishing-ux-hardening` → `main` at `b81016d`, 2026-04-11). Stage 2 complete.
|
||||||
|
- ✅ Multi-select groundwork landed (Stage 3A complete — merged `feat/multi-select-groundwork` → `main`, 2026-05-22).
|
||||||
|
- ✅ List efficiency improvements landed (Stage 3B complete — merged `feat/list-efficiency-improvements` → `main`, 2026-05-22). Stage 3 complete.
|
||||||
|
- ✅ Design system expansion and shared widget migration landed (Stage 4A complete — merged `feat/design-system-shared-widgets` → `main`, 2026-05-22).
|
||||||
|
- ✅ Cross-platform shell composition strategy landed (Stage 4B complete — merged `feat/shared-composition-pattern` → `main`, 2026-05-22).
|
||||||
|
- ✅ Flutter CI/CD pipeline landed (Stage 4C complete — merged `feat/flutter-cicd` → `main`, 2026-05-22).
|
||||||
|
- ✅ Test coverage visibility landed (Stage 4D complete — merged `feat/test-coverage-visibility` → `main`, 2026-05-22).
|
||||||
|
|
||||||
### Current narrow edit capabilities on `main`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
- update product status through controlled workflow
|
- update product status through controlled workflow
|
||||||
- update product price only
|
- update product price only
|
||||||
- update product name only
|
- update product name only
|
||||||
|
- update product description only
|
||||||
|
- update product category only
|
||||||
|
|
||||||
### Latest known validation state on `main`
|
### Latest known validation state on `main`
|
||||||
|
|
||||||
- `flutter analyze` clean
|
- `dart analyze` clean
|
||||||
|
- `core` tests passing
|
||||||
|
- `design_system` tests passing
|
||||||
- `feature_wordpress` tests passing
|
- `feature_wordpress` tests passing
|
||||||
- `kell_web` dashboard tests passing
|
- `kell_web` tests passing
|
||||||
- latest reported count for `feature_wordpress`: `195/195 passed`
|
- latest reported count for `core`: `20/20 passed`
|
||||||
- latest reported count for `kell_web` dashboard tests: `10/10 passed`
|
- latest reported count for `design_system`: `41/41 passed`
|
||||||
|
- latest reported count for `feature_wordpress`: `294/294 passed`
|
||||||
|
- latest reported count for `kell_web`: `24/24 passed`
|
||||||
|
- baseline commit: merge of `feat/test-coverage-visibility` (2026-05-22)
|
||||||
|
|
||||||
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
| Package | Tests | Lines Hit | Lines Found | Coverage |
|
||||||
|
| ------------------- | ------- | --------- | ----------- | --------- |
|
||||||
|
| `core` | 20 | 42 | 49 | 85.7% |
|
||||||
|
| `design_system` | 41 | 88 | 88 | 100.0% |
|
||||||
|
| `feature_wordpress` | 294 | 857 | 1012 | 84.7% |
|
||||||
|
| `kell_web` | 24 | 191 | 353 | 54.1% |
|
||||||
|
| **Total** | **379** | **1178** | **1502** | **78.4%** |
|
||||||
|
|
||||||
|
No minimum thresholds are enforced — this is visibility-only tracking. Coverage is measured via `flutter test --coverage` (generates `lcov.info`) and reported in the CI workflow summary table.
|
||||||
|
|
||||||
|
### Next recommended branch
|
||||||
|
|
||||||
|
**`feat/android-app-shell`** — Stage 5A: Android app shell and bootstrap.
|
||||||
|
Branch from latest `main`. Stage 4 (Platform foundations and cross-platform readiness) is complete.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -126,59 +169,15 @@ Use one branch per edit slice:
|
||||||
- `feat/description-only-edit`
|
- `feat/description-only-edit`
|
||||||
- `feat/category-only-edit`
|
- `feat/category-only-edit`
|
||||||
|
|
||||||
#### Stage 1A — Description-only product edit
|
#### ~~Stage 1A — Description-only product edit~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Goal
|
> Merged `feat/description-only-edit` → `main` at `cebac4c` (2026-04-11).
|
||||||
|
> All artifacts delivered: repository contract, fake/WP repo implementations, use case, controller action/result handling, preview panel inline description edit UI, and targeted tests (212 total `feature_wordpress` tests passing).
|
||||||
|
|
||||||
Allow updating product description only.
|
#### ~~Stage 1B — Category-only product edit~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Requirements
|
> Merged `feat/category-only-edit` → `main` at `8e7e4cb` (2026-04-11).
|
||||||
|
> All artifacts delivered: repository contract, fake/WP repo implementations, use case, controller action/result handling, preview panel inline category edit UI, snack bar feedback, and targeted tests (223 total `feature_wordpress` tests passing). Stage 1 complete.
|
||||||
- Inspect the existing price-only and name-only edit implementations first.
|
|
||||||
- Mirror the narrow pattern for description update.
|
|
||||||
- Add one repository method or equivalent narrow path only if needed.
|
|
||||||
- Fake and WP repositories evolve in parallel.
|
|
||||||
- Use existing controller result/feedback patterns.
|
|
||||||
- Keep WooCommerce payload logic in WP repo only.
|
|
||||||
- Do not expose any other editable fields.
|
|
||||||
- Keep description editing plain and controlled.
|
|
||||||
- Do not add a rich text editor.
|
|
||||||
|
|
||||||
##### Expected artifacts
|
|
||||||
|
|
||||||
- repository contract update
|
|
||||||
- fake repo implementation
|
|
||||||
- WP repo implementation
|
|
||||||
- use case
|
|
||||||
- controller action/result handling
|
|
||||||
- preview panel inline description edit UI
|
|
||||||
- targeted repo/controller/widget tests
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- product description updates end-to-end in FAKE and WP modes
|
|
||||||
- no generic update API introduced
|
|
||||||
- tests/analyze clean
|
|
||||||
|
|
||||||
#### Stage 1B — Category-only product edit
|
|
||||||
|
|
||||||
##### Goal
|
|
||||||
|
|
||||||
Allow updating product category only.
|
|
||||||
|
|
||||||
##### Requirements
|
|
||||||
|
|
||||||
- Use existing category representation only.
|
|
||||||
- Do not create full taxonomy management.
|
|
||||||
- Keep write mapping narrow inside WP repository.
|
|
||||||
- Use a simple constrained UI.
|
|
||||||
- Reuse the established narrow single-field edit pattern.
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- category can be updated through a controlled workflow
|
|
||||||
- no taxonomy subsystem added
|
|
||||||
- tests/analyze clean
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -193,42 +192,15 @@ Improve operator consistency, predictability, and usability after writes.
|
||||||
- `feat/post-write-consistency`
|
- `feat/post-write-consistency`
|
||||||
- `feat/publishing-ux-hardening`
|
- `feat/publishing-ux-hardening`
|
||||||
|
|
||||||
#### Stage 2A — Post-write consistency hardening
|
#### ~~Stage 2A — Post-write consistency hardening~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Goal
|
> Merged `feat/post-write-consistency` → `main` at `7acff83` (2026-04-11).
|
||||||
|
> Added `_refreshSelection()` to `ProductPublishingController` to preserve/refresh selection after all write-triggered reloads. 11 new post-write consistency tests added (234 total `feature_wordpress` tests passing).
|
||||||
|
|
||||||
Make list/detail behavior predictable after edits and status changes.
|
#### ~~Stage 2B — Publishing workflow UX hardening~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Requirements
|
> Merged `feat/publishing-ux-hardening` → `main` at `b81016d` (2026-04-11).
|
||||||
|
> Added inline validation error messages for all four single-field edits (name, price, description, category), disabled text fields and save buttons during `isUpdating`, and appended error detail to failure snack bars. 13 new tests added (247 total `feature_wordpress` tests passing). Stage 2 complete.
|
||||||
- preserve selection after update where sensible
|
|
||||||
- maintain search/filter/sort persistence after writes
|
|
||||||
- handle item repositioning under active sort cleanly
|
|
||||||
- ensure latest values refresh correctly
|
|
||||||
- verify last-modified updates behave consistently
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- post-action behavior feels stable and predictable
|
|
||||||
- focused tests cover persistence and repositioning
|
|
||||||
|
|
||||||
#### Stage 2B — Publishing workflow UX hardening
|
|
||||||
|
|
||||||
##### Goal
|
|
||||||
|
|
||||||
Tighten the publishing workflow without broadening scope.
|
|
||||||
|
|
||||||
##### Requirements
|
|
||||||
|
|
||||||
- refine success/failure wording if needed
|
|
||||||
- improve inline validation messaging for single-field edits
|
|
||||||
- ensure disabled/loading states are consistent
|
|
||||||
- ensure page-level load errors remain separate from row-level action errors
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- status/edit actions feel reliable and operator-friendly
|
|
||||||
- no architecture broadening
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -243,45 +215,57 @@ Increase throughput for product triage and management using existing data.
|
||||||
- `feat/multi-select-groundwork`
|
- `feat/multi-select-groundwork`
|
||||||
- `feat/list-efficiency-improvements`
|
- `feat/list-efficiency-improvements`
|
||||||
|
|
||||||
#### Stage 3A — Multi-select groundwork (read/state only first)
|
#### ~~Stage 3A — Multi-select groundwork (read/state only first)~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Goal
|
> Merged `feat/multi-select-groundwork` → `main` (2026-05-22).
|
||||||
|
> Added multi-selection state to `ProductPublishingController` (`toggleMultiSelect`, `clearMultiSelection`, `selectAllVisible`, `isMultiSelected`, `multiSelectedIds`, `multiSelectedCount`). Updated `ProductDraftCard` with optional leading checkbox for multi-select mode. Added `_MultiSelectBar` to `ProductPublishingPage` with selected-count display, Select All, and Clear actions. Single-item preview selection preserved independently. 15 new multi-select tests added (262 total `feature_wordpress` tests passing). No bulk writes introduced.
|
||||||
|
|
||||||
Prepare for future bulk actions without implementing bulk writes yet.
|
#### ~~Stage 3B — List efficiency improvements~~ ✅ COMPLETE
|
||||||
|
|
||||||
##### Requirements
|
> Merged `feat/list-efficiency-improvements` → `main` (2026-05-22).
|
||||||
|
> Added compact/standard view toggle (`ListDensity` enum), staleness detection (`isStale()`, `staleCount()` with 30-day threshold), keyboard navigation (`selectNextDraft`/`selectPreviousDraft` with wrapping), description snippet in standard card, and stale indicator icon. Updated `ProductDraftCard` with compact two-row layout variant and `Flexible` overflow handling. Page wires up `Focus`+`onKeyEvent` for arrow keys and density toggle button. 32 new tests added (294 total `feature_wordpress` tests passing). Stage 3 complete.
|
||||||
- add selection model for multiple items
|
|
||||||
- show selected-count UI
|
|
||||||
- allow clear selection
|
|
||||||
- preserve current single-item preview behavior where appropriate
|
|
||||||
- do not add bulk publish/edit/delete actions yet
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- multi-select state exists and is tested
|
|
||||||
- no bulk writes introduced
|
|
||||||
|
|
||||||
#### Stage 3B — List efficiency improvements
|
|
||||||
|
|
||||||
##### Goal
|
|
||||||
|
|
||||||
Further improve operator productivity.
|
|
||||||
|
|
||||||
##### Candidate scope
|
|
||||||
|
|
||||||
- denser list/card presentation if justified
|
|
||||||
- quick visual indicators for stale products
|
|
||||||
- lightweight secondary metadata visibility
|
|
||||||
- improved keyboard/focus handling on web if easy to support
|
|
||||||
|
|
||||||
##### Definition of done
|
|
||||||
|
|
||||||
- measurable usability improvement using existing data only
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Stage 4 — Android application foundation
|
### Stage 4 — Platform foundations and cross-platform readiness
|
||||||
|
|
||||||
|
#### Objective
|
||||||
|
|
||||||
|
Strengthen the shared platform infrastructure before expanding to Android. This stage implements the key recommendations from the codebase analysis to reduce technical debt, prevent duplication, and establish quality gates.
|
||||||
|
|
||||||
|
#### Key principle
|
||||||
|
|
||||||
|
Invest in shared foundations now so that the Android expansion (Stage 5) and all future feature work benefits from a mature design system, shared composition patterns, automated CI/CD, and proper test coverage tracking.
|
||||||
|
|
||||||
|
#### Branches
|
||||||
|
|
||||||
|
- `feat/design-system-shared-widgets`
|
||||||
|
- `feat/flutter-cicd`
|
||||||
|
- `feat/shared-composition-pattern`
|
||||||
|
|
||||||
|
#### ~~Stage 4A — Design system expansion and shared widget migration~~ ✅ COMPLETE
|
||||||
|
|
||||||
|
> Merged `feat/design-system-shared-widgets` → `main` (2026-05-22).
|
||||||
|
> Migrated `EmptyStatePanel`, `SectionHeader`, and `SummaryCard` from `kell_web/lib/shell/widgets/` into `design_system` as `KcEmptyState`, `KcSectionHeader`, and `KcSummaryCard`. Added `KcTypography` shared typography scale, `KcBreakpoints` responsive layout breakpoint utilities, `KcLoadingState` and `KcErrorState` shared state widgets. Updated `kell_web` dashboard to use design_system widgets directly; shell widget files now contain backward-compatible typedefs. Theme updated to use `KcTypography.applyKcTypography()`. 41 new design_system tests added (41 total `design_system` tests, 294 `feature_wordpress` tests, 24 `kell_web` tests — all passing). Analyze clean.
|
||||||
|
|
||||||
|
#### ~~Stage 4B — Cross-platform shell composition strategy~~ ✅ COMPLETE
|
||||||
|
|
||||||
|
> Merged `feat/shared-composition-pattern` → `main` at `0a0abc2` (2026-05-22).
|
||||||
|
> Extracted shared composition abstractions into `core` package: `KcAppConfig`, `KcAppEnvironment`, `KcAppServices` (abstract), `KcServiceFactory<T>`, `KcBootstrap`, `KcAppScope<T>`. Updated `kell_web` to delegate to shared types via backward-compatible typedefs (`AppConfig`, `AppEnvironment`, `AppServices`, `AppScope`, `Bootstrap`). Contract for new app targets documented in `kell_creations_apps/docs/composition-strategy.md`. `kell_web` runtime behavior unchanged. 20 core tests added covering all composition abstractions (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web — all passing). Analyze clean.
|
||||||
|
|
||||||
|
#### ~~Stage 4C — Flutter CI/CD pipeline~~ ✅ COMPLETE
|
||||||
|
|
||||||
|
> Merged `feat/flutter-cicd` → `main` (2026-05-22).
|
||||||
|
> Added Forgejo Actions workflows: `flutter-analyze.yml` (runs `dart analyze --fatal-infos` on all 8 packages/apps) and `flutter-test.yml` (runs `flutter test` per package with per-package pass/fail count and aggregate summary table). Workflows trigger on PRs to main and all non-main branch pushes using `ghcr.io/cirruslabs/flutter:stable` container. Populated `tools/` directory with `run_all_tests.sh` (local test runner with optional `--analyze` flag) and `README.md` documenting scripts and CI workflow inventory. All existing tests passing (20 core, 41 design_system, 294 feature_wordpress, 24 kell_web — 379 total). Analyze clean.
|
||||||
|
|
||||||
|
#### ~~Stage 4D — Test coverage visibility~~ ✅ COMPLETE
|
||||||
|
|
||||||
|
> Merged `feat/test-coverage-visibility` → `main` (2026-05-22).
|
||||||
|
> Enhanced `flutter-test.yml` CI workflow to run `flutter test --coverage` and parse `lcov.info` files, producing an aggregate summary table with per-package pass/fail counts and line coverage percentages. Added `collect_coverage.sh` local coverage helper script and updated `tools/README.md`. Established baseline coverage: core 85.7% (42/49), design_system 100.0% (88/88), feature_wordpress 84.7% (857/1012), kell_web 54.1% (191/353) — overall 78.4% (1178/1502). No minimum thresholds enforced — visibility first. All 379 tests passing. Analyze clean. Stage 4 complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stage 5 — Android application foundation
|
||||||
|
|
||||||
#### Objective
|
#### Objective
|
||||||
|
|
||||||
|
|
@ -296,7 +280,7 @@ Business logic, domain logic, repositories, and feature application logic should
|
||||||
- `feat/android-app-shell`
|
- `feat/android-app-shell`
|
||||||
- `feat/android-publishing-surface`
|
- `feat/android-publishing-surface`
|
||||||
|
|
||||||
#### Stage 4A — Android app shell and bootstrap
|
#### Stage 5A — Android app shell and bootstrap
|
||||||
|
|
||||||
##### Goal
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -310,13 +294,18 @@ Create the Android app entry and shell for the existing platform.
|
||||||
- ensure FAKE mode works cleanly on Android first
|
- ensure FAKE mode works cleanly on Android first
|
||||||
- mobile shell/navigation should stay simple and consistent with shared app structure
|
- mobile shell/navigation should stay simple and consistent with shared app structure
|
||||||
|
|
||||||
|
##### Current state note
|
||||||
|
|
||||||
|
> `kell_mobile` exists as a default Flutter counter template. It does **not** yet reference any shared packages (`core`, `design_system`, `feature_*`). This stage must replace the template with a proper app shell that mirrors the `kell_web` composition pattern (`AppServices`, `AppScope`, routing, shell).
|
||||||
|
|
||||||
##### Definition of done
|
##### Definition of done
|
||||||
|
|
||||||
- app runs on Android emulator/device in FAKE mode
|
- app runs on Android emulator/device in FAKE mode
|
||||||
- shell, navigation, and core screens render
|
- shell, navigation, and core screens render
|
||||||
- analyze/tests remain clean
|
- analyze/tests remain clean
|
||||||
|
- `kell_mobile/pubspec.yaml` references shared packages
|
||||||
|
|
||||||
#### Stage 4B — Android publishing surface
|
#### Stage 5B — Android publishing surface
|
||||||
|
|
||||||
##### Goal
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -336,7 +325,7 @@ Adapt the publishing workflow for mobile form factor.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Stage 5 — Android operational maturity
|
### Stage 6 — Android operational maturity
|
||||||
|
|
||||||
#### Objective
|
#### Objective
|
||||||
|
|
||||||
|
|
@ -347,7 +336,7 @@ Harden Android UX after the core feature surface works.
|
||||||
- `feat/android-feedback-polish`
|
- `feat/android-feedback-polish`
|
||||||
- `feat/android-mobile-ux-hardening`
|
- `feat/android-mobile-ux-hardening`
|
||||||
|
|
||||||
#### Stage 5A — Android feedback and action polish
|
#### Stage 6A — Android feedback and action polish
|
||||||
|
|
||||||
##### Goal
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -359,7 +348,7 @@ Ensure action feedback patterns translate cleanly to Android.
|
||||||
- adapt SnackBar/feedback timing and presentation appropriately
|
- adapt SnackBar/feedback timing and presentation appropriately
|
||||||
- validate status/edit workflows on mobile
|
- validate status/edit workflows on mobile
|
||||||
|
|
||||||
#### Stage 5B — Android mobile workflow hardening
|
#### Stage 6B — Android mobile workflow hardening
|
||||||
|
|
||||||
##### Goal
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -373,7 +362,7 @@ Improve ergonomics on smaller screens.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Stage 6 — Controlled bulk actions (only after groundwork)
|
### Stage 7 — Controlled bulk actions (only after groundwork)
|
||||||
|
|
||||||
#### Objective
|
#### Objective
|
||||||
|
|
||||||
|
|
@ -405,6 +394,127 @@ Do not begin until:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Suggested improvements (identified from codebase analysis)
|
||||||
|
|
||||||
|
This section captures structural improvements, coverage gaps, and maturity enhancements identified through codebase analysis against the architecture documentation and current implementation state. Items here are candidates for future stages or can be woven into existing stages as appropriate.
|
||||||
|
|
||||||
|
### 1. Stub package activation roadmap
|
||||||
|
|
||||||
|
The following packages exist as scaffolded stubs with no implementation. Each needs an activation plan aligned to architecture documentation:
|
||||||
|
|
||||||
|
| Package | Architecture Reference | Priority | Notes |
|
||||||
|
| ----------------- | --------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `auth` | Enterprise Identity & Access Architecture | High | No auth layer exists; all access is currently uncontrolled. Required before any multi-user or production deployment. |
|
||||||
|
| `data` | Enterprise Data Architecture | Medium | Shared data abstractions/caching could reduce duplication across feature repositories. |
|
||||||
|
| `integrations` | Enterprise Integration & Orchestration Architecture | Medium | Shared integration contracts could formalize API client patterns currently embedded in `feature_wordpress`. |
|
||||||
|
| `feature_finance` | Financial Analysis Components | Low | Architecture docs exist but no implementation. Defer until core WordPress/Inventory workflows mature. |
|
||||||
|
| `feature_mrp` | Craft Manufacturing / MRP Components | Low | Architecture docs exist but no implementation. Defer until inventory workflows mature. |
|
||||||
|
| `feature_social` | Social Media Management Components | Low | Architecture docs exist but no implementation. Defer until core platform stabilizes. |
|
||||||
|
|
||||||
|
**Recommendation:** Add a **Stage 8 — Infrastructure package activation** to the roadmap covering `auth`, `data`, and `integrations` before expanding into new feature domains.
|
||||||
|
|
||||||
|
### ~~2. Design system expansion~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
> Addressed in **Stage 4A** (Design system expansion and shared widget migration), merged 2026-05-22.
|
||||||
|
> All previously identified gaps have been resolved:
|
||||||
|
>
|
||||||
|
> - ✅ Shared typography scale → `KcTypography` with full Material 3 hierarchy and `applyKcTypography()` helper
|
||||||
|
> - ✅ Responsive layout utilities → `KcBreakpoints` with compact/medium/expanded/large queries and grid column helper
|
||||||
|
> - ✅ Shared empty state, loading state, error state patterns → `KcEmptyState`, `KcLoadingState`, `KcErrorState`
|
||||||
|
> - ✅ Shell widgets migrated → `EmptyStatePanel`, `SectionHeader`, `SummaryCard` moved from `kell_web` to `design_system` as `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`; backward-compatible typedefs retained in `kell_web`
|
||||||
|
>
|
||||||
|
> The `design_system` package now contains: theme (`KcColors`, `KcSpacing`, `KcTheme`), typography (`KcTypography`), layout (`KcBreakpoints`), and 7 widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`). 41 tests, 100% line coverage.
|
||||||
|
>
|
||||||
|
> **Remaining gap:** No shared button/action component library. This can be addressed in a future stage as needed.
|
||||||
|
|
||||||
|
### ~~3. Cross-platform shell composition strategy~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
> Addressed in **Stage 4B** (Cross-platform shell composition strategy), merged `feat/shared-composition-pattern` → `main` at `0a0abc2` (2026-05-22).
|
||||||
|
> Shared composition abstractions extracted into `core` package: `KcAppConfig`, `KcAppEnvironment`, `KcAppServices` (abstract), `KcServiceFactory<T>`, `KcBootstrap`, `KcAppScope<T>`. `kell_web` updated to delegate to shared types via backward-compatible typedefs. Contract for new app targets (including `kell_mobile`) documented in `kell_creations_apps/docs/composition-strategy.md`. 20 core tests passing. Analyze clean.
|
||||||
|
|
||||||
|
### ~~4. CI/CD for Flutter applications~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
> Addressed in **Stage 4C** (Flutter CI/CD pipeline), merged `feat/flutter-cicd` → `main` at `6a6323e` (2026-05-22).
|
||||||
|
> Two Forgejo Actions workflows established: `flutter-analyze.yml` (runs `dart analyze --fatal-infos` on all 8 packages/apps) and `flutter-test.yml` (runs `flutter test` per package with aggregate summary table). Workflows trigger on PRs to main and all non-main branch pushes. Local CI helper scripts added to `tools/`.
|
||||||
|
>
|
||||||
|
> **Remaining gap:** Flutter build validation (web + Android) is not yet automated in CI. This can be added as Android work progresses in Stage 5.
|
||||||
|
|
||||||
|
### 5. ~~Test coverage visibility and quality gates~~ ✅ PARTIALLY RESOLVED
|
||||||
|
|
||||||
|
> Automated test count reporting and line coverage measurement now addressed in Stage 4D (test coverage visibility). CI workflow produces per-package pass/fail counts and coverage percentages. Baseline documented. Minimum threshold enforcement and trend tracking remain future enhancements.
|
||||||
|
|
||||||
|
### ~~6. Build execution tracker synchronization~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
> The `build_execution_tracker.md` has been fully updated through Stage 4D. All 10 merged branches are tracked with status, dates, files changed, test counts, and analysis results. Current status correctly reflects Stage 4 complete and next branch as `feat/android-app-shell` (Stage 5A). Updated as part of the Stage 4C/4D merge cycle (2026-05-22).
|
||||||
|
|
||||||
|
### 7. Image and media management
|
||||||
|
|
||||||
|
Architecture documentation (`traceability-index.md` coverage gaps) identifies "Product image / media synchronization workflow" as a gap. The current publishing workflow handles text fields only.
|
||||||
|
|
||||||
|
**Recommendation:** Plan a future stage for image/media management once existing narrow edits are stable on both platforms. This aligns with the architecture gap analysis.
|
||||||
|
|
||||||
|
### 8. Error tracking and observability
|
||||||
|
|
||||||
|
No monitoring, error tracking, or analytics strategy exists for the Flutter applications. In production use:
|
||||||
|
|
||||||
|
- Runtime errors are invisible unless the operator sees them
|
||||||
|
- No crash reporting
|
||||||
|
- No usage analytics to guide prioritization
|
||||||
|
|
||||||
|
**Recommendation:** Add lightweight error reporting and observability as a future stage, particularly important before any production WP-mode deployment beyond a single operator.
|
||||||
|
|
||||||
|
### 9. ~~Empty tools directory~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
> Now populated with `run_all_tests.sh` and `README.md` as part of Stage 4C (Flutter CI/CD pipeline).
|
||||||
|
|
||||||
|
### ~~10. Missing feature parity documentation~~ ✅ PARTIALLY RESOLVED
|
||||||
|
|
||||||
|
> A **feature maturity matrix** has been added to this brief (see **Appendix: Feature maturity matrix** at the end of this document). It maps all 12 packages across domain, application, data (fake/real), presentation, test count, and maturity level. Added 2026-05-22 during full project analysis.
|
||||||
|
>
|
||||||
|
> **Remaining gap:** The matrix does not yet map architecture documentation aspirations to implementation status per feature (e.g., which architecture-described capabilities exist vs. are missing within each feature package). This can be expanded as feature packages mature.
|
||||||
|
|
||||||
|
### 11. Policy register CSVs are empty
|
||||||
|
|
||||||
|
The policy repository has 2 active governance documents (`KC-POL-GOV-001`, `KC-PRO-GOV-001`), but the register CSVs (`policy-register.csv`, `document-control-log.csv`, `review-calendar.csv`) contain headers only with no data rows. This means the controlled document lifecycle described in the Document Control Policy is not being followed for the governance documents themselves.
|
||||||
|
|
||||||
|
**Recommendation:** Populate the register CSVs with entries for the 2 active governance documents. This should be done before any additional policies are created.
|
||||||
|
|
||||||
|
### 12. `feature_orders` package missing standard scaffolding
|
||||||
|
|
||||||
|
The `feature_orders` package is missing standard scaffolding files (`.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md`) that are present in all other packages. This creates inconsistency across the monorepo.
|
||||||
|
|
||||||
|
**Recommendation:** Add the missing files to `feature_orders` to match the structure of other packages. This is a low-effort, high-consistency improvement.
|
||||||
|
|
||||||
|
### 13. Architecture documentation shows wrong diagram
|
||||||
|
|
||||||
|
The Enterprise Data Architecture documentation page (`docs/architecture/containers/enterprise-data-architecture.md`) was referencing the wrong SVG image (showing the enterprise services diagram instead of the data architecture diagram).
|
||||||
|
|
||||||
|
**Resolution:** ✅ Fixed 2026-05-22 during full project analysis. Image reference corrected to `enterprise-data-architecture.svg`.
|
||||||
|
|
||||||
|
### 14. Missing diagram source references in architecture docs
|
||||||
|
|
||||||
|
Three architecture documentation pages were missing the standard "Diagram Source" section pointing to their PlantUML file:
|
||||||
|
|
||||||
|
- `docs/architecture/system-landscape.md`
|
||||||
|
- `docs/architecture/containers/platform-containers.md`
|
||||||
|
- `docs/architecture/components/inventory.md`
|
||||||
|
|
||||||
|
**Resolution:** ✅ Fixed 2026-05-22 during full project analysis. Diagram source references added for consistency.
|
||||||
|
|
||||||
|
### 15. Empty documentation sections need initial content
|
||||||
|
|
||||||
|
The Standards, Integrations, and Brand documentation sections had placeholder-only index pages with no substantive content.
|
||||||
|
|
||||||
|
**Resolution:** ✅ Populated 2026-05-22 during full project analysis. Each section now includes current state assessment, recommendations, and actionable next steps.
|
||||||
|
|
||||||
|
### 16. ADR index needs retroactive entries
|
||||||
|
|
||||||
|
The Architecture Decision Record (ADR) index exists but contained no entries. Seven significant architecture decisions are already in effect across the platform.
|
||||||
|
|
||||||
|
**Resolution:** ✅ Populated 2026-05-22 during full project analysis. Seven candidate ADRs documented with status, context, decision, and consequences.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Required development workflow for every slice
|
## Required development workflow for every slice
|
||||||
|
|
||||||
For every branch/stage:
|
For every branch/stage:
|
||||||
|
|
@ -419,6 +529,7 @@ For every branch/stage:
|
||||||
8. Summarize changed files and validation results.
|
8. Summarize changed files and validation results.
|
||||||
9. Open PR into `main`.
|
9. Open PR into `main`.
|
||||||
10. Merge only after clean review and validation.
|
10. Merge only after clean review and validation.
|
||||||
|
11. **Update this brief and the build execution tracker** after merge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -450,4 +561,24 @@ Working rules:
|
||||||
5. Add/update focused tests.
|
5. Add/update focused tests.
|
||||||
6. Run validation.
|
6. Run validation.
|
||||||
7. Report changed files and validation results.
|
7. Report changed files and validation results.
|
||||||
|
8. Update master_development_brief.md and build_execution_tracker.md after merge.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Feature maturity matrix
|
||||||
|
|
||||||
|
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
|
||||||
|
| ------------------- | ------------ | ----------------- | ----------- | -------------- | -------------------------------------------------- | ------- | -------------------- |
|
||||||
|
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** |
|
||||||
|
| `feature_inventory` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
|
||||||
|
| `feature_orders` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Some | **Fake-only MVP** |
|
||||||
|
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
|
||||||
|
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
|
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
|
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
|
| `auth` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | N/A | None | **Scaffolded only** |
|
||||||
|
| `data` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** |
|
||||||
|
| `integrations` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** |
|
||||||
|
| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Foundation ready** |
|
||||||
|
| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 20 | **Foundation ready** |
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,49 @@
|
||||||
# Kell Creations Platform
|
# Kell Creations Platform
|
||||||
|
|
||||||
This site is the central documentation portal for Kell Creations applications, services, architecture, policies, and operating processes.
|
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,3 +1,71 @@
|
||||||
# Integrations Overview
|
# Integrations Overview
|
||||||
|
|
||||||
This section documents WordPress, n8n, mail, and social media integrations.
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,108 @@
|
||||||
# Operations Overview
|
# Operations Overview
|
||||||
|
|
||||||
This section contains operational runbooks and business procedures.
|
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 |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,49 @@ The initial governance set includes:
|
||||||
- Document Control Policy
|
- Document Control Policy
|
||||||
- Policy Review and Retirement Procedure
|
- 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
|
## 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,3 +1,50 @@
|
||||||
# Standards Overview
|
# Standards Overview
|
||||||
|
|
||||||
This section contains technical, documentation, and business standards.
|
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).
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,18 @@
|
||||||
/// Runtime configuration read from `--dart-define` values.
|
/// Re-exports [KcAppConfig] and [KcAppEnvironment] from the shared `core` package.
|
||||||
///
|
///
|
||||||
/// The app reads the following compile-time constants:
|
/// This file preserves backward compatibility for existing imports within
|
||||||
|
/// `kell_web`. New code should import directly from `package:core/core.dart`.
|
||||||
///
|
///
|
||||||
/// | Key | Description | Default |
|
/// The original class names are preserved as typedefs so that existing
|
||||||
/// |------------------------|----------------------------------------------|----------|
|
/// references continue to compile without changes.
|
||||||
/// | `KC_ENV` | `fake` or `wordpress` | `fake` |
|
library;
|
||||||
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
|
|
||||||
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
|
||||||
/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) |
|
|
||||||
///
|
|
||||||
/// Usage:
|
|
||||||
/// ```sh
|
|
||||||
/// flutter run -d chrome \
|
|
||||||
/// --dart-define=KC_ENV=wordpress \
|
|
||||||
/// --dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \
|
|
||||||
/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
|
||||||
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
|
||||||
/// ```
|
|
||||||
class AppConfig {
|
|
||||||
/// The environment mode: `fake` or `wordpress`.
|
|
||||||
final AppEnvironment environment;
|
|
||||||
|
|
||||||
/// WordPress / WooCommerce site URL (only used in [AppEnvironment.wordpress]).
|
export 'package:core/core.dart' show KcAppConfig, KcAppEnvironment;
|
||||||
final String wcSiteUrl;
|
|
||||||
|
|
||||||
/// WooCommerce REST API consumer key.
|
import 'package:core/core.dart';
|
||||||
final String wcConsumerKey;
|
|
||||||
|
|
||||||
/// WooCommerce REST API consumer secret.
|
/// @Deprecated('Use KcAppConfig from core instead.')
|
||||||
final String wcConsumerSecret;
|
typedef AppConfig = KcAppConfig;
|
||||||
|
|
||||||
const AppConfig({
|
/// @Deprecated('Use KcAppEnvironment from core instead.')
|
||||||
required this.environment,
|
typedef AppEnvironment = KcAppEnvironment;
|
||||||
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,18 +1,30 @@
|
||||||
|
/// 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 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'app_config.dart';
|
|
||||||
import 'app_services.dart';
|
import 'app_services.dart';
|
||||||
|
|
||||||
/// An [InheritedWidget] that exposes [AppServices] and [AppConfig] to the
|
export 'package:core/core.dart' show KcAppScope;
|
||||||
/// widget tree.
|
|
||||||
///
|
|
||||||
/// Wrap the app (or a subtree) with [AppScope] and retrieve the services
|
|
||||||
/// anywhere below via [AppScope.of(context)].
|
|
||||||
class AppScope extends InheritedWidget {
|
|
||||||
final AppServices services;
|
|
||||||
final AppConfig config;
|
|
||||||
|
|
||||||
const AppScope({super.key, required this.services, required this.config, required super.child});
|
/// 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});
|
||||||
|
|
||||||
/// Returns the nearest [AppServices] from the widget tree.
|
/// Returns the nearest [AppServices] from the widget tree.
|
||||||
///
|
///
|
||||||
|
|
@ -23,16 +35,12 @@ class AppScope extends InheritedWidget {
|
||||||
return scope!.services;
|
return scope!.services;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the nearest [AppConfig] from the widget tree.
|
/// Returns the nearest [KcAppConfig] from the widget tree.
|
||||||
///
|
///
|
||||||
/// Throws if no [AppScope] ancestor is found.
|
/// Throws if no [AppScope] ancestor is found.
|
||||||
static AppConfig configOf(BuildContext context) {
|
static KcAppConfig configOf(BuildContext context) {
|
||||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||||
assert(scope != null, 'No AppScope found in the widget tree');
|
assert(scope != null, 'No AppScope found in the widget tree');
|
||||||
return scope!.config;
|
return scope!.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
bool updateShouldNotify(AppScope oldWidget) =>
|
|
||||||
services != oldWidget.services || config != oldWidget.config;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
|
import 'package:core/core.dart';
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
import 'package:feature_inventory/feature_inventory.dart';
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
|
||||||
/// Holds the concrete service implementations used by the app.
|
/// 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.
|
||||||
///
|
///
|
||||||
/// The [AppServices.fake] factory wires up the in-memory fakes that live
|
/// The [AppServices.fake] factory wires up the in-memory fakes that live
|
||||||
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
||||||
/// a real WooCommerce-backed product repository while keeping other services
|
/// a real WooCommerce-backed product repository while keeping other services
|
||||||
/// fake until their backends are ready.
|
/// fake until their backends are ready.
|
||||||
class AppServices {
|
class AppServices extends KcAppServices {
|
||||||
final InventoryRepository inventoryRepository;
|
final InventoryRepository inventoryRepository;
|
||||||
final OrdersRepository ordersRepository;
|
final OrdersRepository ordersRepository;
|
||||||
final PolicyRepository policyRepository;
|
final PolicyRepository policyRepository;
|
||||||
|
|
@ -57,4 +62,16 @@ class AppServices {
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||||
|
static KcServiceFactory<AppServices> get serviceFactory {
|
||||||
|
return KcServiceFactory<AppServices>(
|
||||||
|
createFake: () => AppServices.fake(),
|
||||||
|
createWordPress: (config) => AppServices.wordpress(
|
||||||
|
siteUrl: config.wcSiteUrl,
|
||||||
|
consumerKey: config.wcConsumerKey,
|
||||||
|
consumerSecret: config.wcConsumerSecret,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
import 'package:flutter/foundation.dart';
|
/// 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 'app_config.dart';
|
|
||||||
import 'app_services.dart';
|
import 'app_services.dart';
|
||||||
|
|
||||||
/// Bootstraps [AppServices] from the runtime [AppConfig].
|
export 'package:core/core.dart' show KcBootstrap;
|
||||||
|
|
||||||
|
/// Backward-compatible bootstrap for `kell_web`.
|
||||||
///
|
///
|
||||||
/// In **fake** mode the in-memory fakes are used unconditionally.
|
/// Delegates to [KcBootstrap.run] using the [AppServices.serviceFactory].
|
||||||
///
|
///
|
||||||
/// In **wordpress** mode the WooCommerce credentials are validated first.
|
/// Existing call sites:
|
||||||
/// If any credential is missing the app falls back to fake mode and logs a
|
/// ```dart
|
||||||
/// warning so the developer knows what went wrong.
|
/// final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// New code should prefer:
|
||||||
|
/// ```dart
|
||||||
|
/// final (:services, config: effectiveConfig) = KcBootstrap.run(
|
||||||
|
/// config,
|
||||||
|
/// AppServices.serviceFactory,
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
class Bootstrap {
|
class Bootstrap {
|
||||||
const Bootstrap._();
|
const Bootstrap._();
|
||||||
|
|
||||||
|
|
@ -18,39 +36,7 @@ class Bootstrap {
|
||||||
/// Returns a record containing the resolved services and the effective
|
/// Returns a record containing the resolved services and the effective
|
||||||
/// config (which may differ from the input when WordPress credentials are
|
/// config (which may differ from the input when WordPress credentials are
|
||||||
/// missing and a fallback to fake mode occurs).
|
/// missing and a fallback to fake mode occurs).
|
||||||
static ({AppServices services, AppConfig config}) run(AppConfig config) {
|
static ({AppServices services, KcAppConfig config}) run(KcAppConfig config) {
|
||||||
switch (config.environment) {
|
return KcBootstrap.run<AppServices>(config, AppServices.serviceFactory);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@ import '../dashboard/application/dashboard_controller.dart';
|
||||||
import '../dashboard/domain/dashboard_summary.dart';
|
import '../dashboard/domain/dashboard_summary.dart';
|
||||||
import '../navigation/app_navigation.dart';
|
import '../navigation/app_navigation.dart';
|
||||||
import '../routing/app_routes.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.
|
/// The main dashboard page showing aggregated summary data.
|
||||||
///
|
///
|
||||||
|
|
@ -68,11 +65,11 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
const SectionHeader(title: 'Overview'),
|
const KcSectionHeader(title: 'Overview'),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
_buildSummaryGrid(context, summary),
|
_buildSummaryGrid(context, summary),
|
||||||
const SizedBox(height: KcSpacing.xl),
|
const SizedBox(height: KcSpacing.xl),
|
||||||
SectionHeader(
|
KcSectionHeader(
|
||||||
title: 'Quick Actions',
|
title: 'Quick Actions',
|
||||||
action: TextButton(
|
action: TextButton(
|
||||||
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory),
|
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory),
|
||||||
|
|
@ -82,9 +79,9 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
_buildQuickActions(context),
|
_buildQuickActions(context),
|
||||||
const SizedBox(height: KcSpacing.xl),
|
const SizedBox(height: KcSpacing.xl),
|
||||||
const SectionHeader(title: 'Recent Activity'),
|
const KcSectionHeader(title: 'Recent Activity'),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
const EmptyStatePanel(
|
const KcEmptyState(
|
||||||
icon: Icons.history,
|
icon: Icons.history,
|
||||||
message:
|
message:
|
||||||
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
|
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
|
||||||
|
|
@ -108,56 +105,56 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final cards = [
|
final cards = [
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.inventory_2,
|
icon: Icons.inventory_2,
|
||||||
iconColor: KcColors.denimBlue,
|
iconColor: KcColors.denimBlue,
|
||||||
label: 'Total Products',
|
label: 'Total Products',
|
||||||
value: '${summary.totalProducts}',
|
value: '${summary.totalProducts}',
|
||||||
onTap: () => AppNavigation.dashboardToInventory(context),
|
onTap: () => AppNavigation.dashboardToInventory(context),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.check_circle_outline,
|
icon: Icons.check_circle_outline,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'In Stock',
|
label: 'In Stock',
|
||||||
value: '${summary.inStock}',
|
value: '${summary.inStock}',
|
||||||
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'),
|
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
iconColor: KcColors.warning,
|
iconColor: KcColors.warning,
|
||||||
label: 'Low Stock',
|
label: 'Low Stock',
|
||||||
value: '${summary.lowStock}',
|
value: '${summary.lowStock}',
|
||||||
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'),
|
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.edit_note,
|
icon: Icons.edit_note,
|
||||||
iconColor: KcColors.neutral,
|
iconColor: KcColors.neutral,
|
||||||
label: 'Draft',
|
label: 'Draft',
|
||||||
value: '${summary.draftProducts}',
|
value: '${summary.draftProducts}',
|
||||||
onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'),
|
onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.receipt_long,
|
icon: Icons.receipt_long,
|
||||||
iconColor: KcColors.denimBlue,
|
iconColor: KcColors.denimBlue,
|
||||||
label: 'Total Orders',
|
label: 'Total Orders',
|
||||||
value: '${summary.totalOrders}',
|
value: '${summary.totalOrders}',
|
||||||
onTap: () => AppNavigation.dashboardToOrders(context),
|
onTap: () => AppNavigation.dashboardToOrders(context),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.hourglass_empty,
|
icon: Icons.hourglass_empty,
|
||||||
iconColor: KcColors.warning,
|
iconColor: KcColors.warning,
|
||||||
label: 'Pending Orders',
|
label: 'Pending Orders',
|
||||||
value: '${summary.pendingOrders}',
|
value: '${summary.pendingOrders}',
|
||||||
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'),
|
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.local_shipping_outlined,
|
icon: Icons.local_shipping_outlined,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'Active Orders',
|
label: 'Active Orders',
|
||||||
value: '${summary.activeOrders}',
|
value: '${summary.activeOrders}',
|
||||||
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'),
|
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
KcSummaryCard(
|
||||||
icon: Icons.attach_money,
|
icon: Icons.attach_money,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'Revenue',
|
label: 'Revenue',
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,15 @@
|
||||||
import 'package:design_system/design_system.dart';
|
/// Re-exports [KcEmptyState] from the shared design system.
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A reusable empty-state panel shown when a section has no data yet.
|
|
||||||
///
|
///
|
||||||
/// Displays an [icon], a [message], and an optional [action] widget
|
/// This file preserves backward compatibility for existing imports.
|
||||||
/// (e.g. a button to create the first item).
|
/// New code should import directly from `package:design_system/design_system.dart`.
|
||||||
class EmptyStatePanel extends StatelessWidget {
|
///
|
||||||
final IconData icon;
|
/// The original `EmptyStatePanel` class name is preserved as a typedef
|
||||||
final String message;
|
/// so that existing references continue to compile without changes.
|
||||||
final Widget? action;
|
library;
|
||||||
|
|
||||||
const EmptyStatePanel({super.key, required this.icon, required this.message, this.action});
|
export 'package:design_system/design_system.dart' show KcEmptyState;
|
||||||
|
|
||||||
@override
|
import 'package:design_system/design_system.dart';
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return KcCard(
|
/// @Deprecated('Use KcEmptyState from design_system instead.')
|
||||||
child: Center(
|
typedef EmptyStatePanel = KcEmptyState;
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xl),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 48, color: KcColors.neutral),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.neutral),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (action != null) ...[const SizedBox(height: KcSpacing.md), action!],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,15 @@
|
||||||
import 'package:design_system/design_system.dart';
|
/// Re-exports [KcSectionHeader] from the shared design system.
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A reusable section header used across app pages.
|
|
||||||
///
|
///
|
||||||
/// Displays a [title] with an optional trailing [action] widget
|
/// This file preserves backward compatibility for existing imports.
|
||||||
/// (e.g. a "View all" button).
|
/// New code should import directly from `package:design_system/design_system.dart`.
|
||||||
class SectionHeader extends StatelessWidget {
|
///
|
||||||
final String title;
|
/// The original `SectionHeader` class name is preserved as a typedef
|
||||||
final Widget? action;
|
/// so that existing references continue to compile without changes.
|
||||||
|
library;
|
||||||
|
|
||||||
const SectionHeader({super.key, required this.title, this.action});
|
export 'package:design_system/design_system.dart' show KcSectionHeader;
|
||||||
|
|
||||||
@override
|
import 'package:design_system/design_system.dart';
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
/// @Deprecated('Use KcSectionHeader from design_system instead.')
|
||||||
padding: const EdgeInsets.only(bottom: KcSpacing.sm),
|
typedef SectionHeader = KcSectionHeader;
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
?action,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,15 @@
|
||||||
import 'package:design_system/design_system.dart';
|
/// Re-exports [KcSummaryCard] from the shared design system.
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A stat / summary card that displays a [value] with a [label] and an [icon].
|
|
||||||
///
|
///
|
||||||
/// Used on the dashboard to show high-level KPIs such as total products,
|
/// This file preserves backward compatibility for existing imports.
|
||||||
/// in-stock count, etc. Optionally tappable via [onTap] to navigate to a
|
/// New code should import directly from `package:design_system/design_system.dart`.
|
||||||
/// related page.
|
///
|
||||||
class SummaryCard extends StatelessWidget {
|
/// The original `SummaryCard` class name is preserved as a typedef
|
||||||
final IconData icon;
|
/// so that existing references continue to compile without changes.
|
||||||
final Color iconColor;
|
library;
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
/// Optional tap handler for cross-feature navigation from dashboard KPIs.
|
export 'package:design_system/design_system.dart' show KcSummaryCard;
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const SummaryCard({
|
import 'package:design_system/design_system.dart';
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.iconColor,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
/// @Deprecated('Use KcSummaryCard from design_system instead.')
|
||||||
Widget build(BuildContext context) {
|
typedef SummaryCard = KcSummaryCard;
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
|
||||||
@override
|
@override
|
||||||
Future<ProductDraft> updateProductDescription(String id, String description) =>
|
Future<ProductDraft> updateProductDescription(String id, String description) =>
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductCategory(String id, String category) =>
|
||||||
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StubOrdersRepository implements OrdersRepository {
|
class _StubOrdersRepository implements OrdersRepository {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
# 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,5 +1,17 @@
|
||||||
/// A Calculator.
|
/// Core shared abstractions for Kell Creations applications.
|
||||||
class Calculator {
|
///
|
||||||
/// Returns [value] plus 1.
|
/// This package provides the platform-agnostic composition pattern used by
|
||||||
int addOne(int value) => value + 1;
|
/// 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
|
||||||
|
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';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
/// 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` 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 KcAppConfig {
|
||||||
|
/// The environment mode: `fake` or `wordpress`.
|
||||||
|
final KcAppEnvironment environment;
|
||||||
|
|
||||||
|
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]).
|
||||||
|
final String wcSiteUrl;
|
||||||
|
|
||||||
|
/// WooCommerce REST API consumer key.
|
||||||
|
final String wcConsumerKey;
|
||||||
|
|
||||||
|
/// WooCommerce REST API consumer secret.
|
||||||
|
final String wcConsumerSecret;
|
||||||
|
|
||||||
|
const KcAppConfig({
|
||||||
|
required this.environment,
|
||||||
|
required this.wcSiteUrl,
|
||||||
|
required this.wcConsumerKey,
|
||||||
|
required this.wcConsumerSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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');
|
||||||
|
|
||||||
|
final environment = KcAppEnvironment.fromString(envString);
|
||||||
|
|
||||||
|
return KcAppConfig(
|
||||||
|
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;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is KcAppConfig &&
|
||||||
|
environment == other.environment &&
|
||||||
|
wcSiteUrl == other.wcSiteUrl &&
|
||||||
|
wcConsumerKey == other.wcConsumerKey &&
|
||||||
|
wcConsumerSecret == other.wcConsumerSecret;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(environment, wcSiteUrl, wcConsumerKey, wcConsumerSecret);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'KcAppConfig(environment: ${environment.key}, '
|
||||||
|
'hasWordPressConfig: $hasWordPressConfig)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The supported runtime environments.
|
||||||
|
enum KcAppEnvironment {
|
||||||
|
/// In-memory fakes – no network calls.
|
||||||
|
fake('fake', 'FAKE'),
|
||||||
|
|
||||||
|
/// Real WooCommerce backend.
|
||||||
|
wordpress('wordpress', 'WP');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/// Shared composition abstractions for app service containers.
|
||||||
|
///
|
||||||
|
/// Provides [KcAppServices] as an abstract base and [KcServiceFactory] as
|
||||||
|
/// the generic factory pattern used by [KcBootstrap] to construct services.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'kc_app_config.dart';
|
||||||
|
|
||||||
|
/// Abstract base class for application service containers.
|
||||||
|
///
|
||||||
|
/// Each app target (`kell_web`, `kell_mobile`) creates a concrete subclass
|
||||||
|
/// that holds the specific repository and service implementations needed by
|
||||||
|
/// that target. The base class defines the factory contract that
|
||||||
|
/// [KcBootstrap] uses to construct services from a [KcAppConfig].
|
||||||
|
///
|
||||||
|
/// ## Pattern
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// class WebAppServices extends KcAppServices {
|
||||||
|
/// final InventoryRepository inventoryRepository;
|
||||||
|
/// final ProductPublishingRepository productPublishingRepository;
|
||||||
|
/// // ...
|
||||||
|
///
|
||||||
|
/// const WebAppServices({
|
||||||
|
/// required this.inventoryRepository,
|
||||||
|
/// required this.productPublishingRepository,
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Why abstract?
|
||||||
|
///
|
||||||
|
/// Concrete service containers reference feature-package repository types
|
||||||
|
/// directly. Moving those references into `core` would create circular
|
||||||
|
/// dependencies (`core` → feature → `core`). By keeping the base abstract,
|
||||||
|
/// `core` owns the composition *contract* without knowing concrete types.
|
||||||
|
abstract class KcAppServices {
|
||||||
|
const KcAppServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A factory that constructs the appropriate [KcAppServices] subclass
|
||||||
|
/// from a [KcAppConfig].
|
||||||
|
///
|
||||||
|
/// Each app target provides two factories — one for fake mode and one for
|
||||||
|
/// the real backend — via [KcServiceFactory].
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// final factory = KcServiceFactory<WebAppServices>(
|
||||||
|
/// createFake: () => WebAppServices.fake(),
|
||||||
|
/// createWordPress: (config) => WebAppServices.wordpress(
|
||||||
|
/// siteUrl: config.wcSiteUrl,
|
||||||
|
/// consumerKey: config.wcConsumerKey,
|
||||||
|
/// consumerSecret: config.wcConsumerSecret,
|
||||||
|
/// ),
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
class KcServiceFactory<T extends KcAppServices> {
|
||||||
|
/// Creates services backed by fake, in-memory repositories.
|
||||||
|
final T Function() createFake;
|
||||||
|
|
||||||
|
/// Creates services backed by the real WordPress/WooCommerce backend.
|
||||||
|
///
|
||||||
|
/// Receives the full [KcAppConfig] so the factory can extract credentials.
|
||||||
|
final T Function(KcAppConfig config) createWordPress;
|
||||||
|
|
||||||
|
const KcServiceFactory({required this.createFake, required this.createWordPress});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/// Shared bootstrap logic for all Kell Creations app targets.
|
||||||
|
///
|
||||||
|
/// Uses a [KcServiceFactory] to construct the appropriate services based
|
||||||
|
/// on the runtime [KcAppConfig].
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'kc_app_config.dart';
|
||||||
|
import 'kc_app_services.dart';
|
||||||
|
|
||||||
|
/// Bootstraps app services from the runtime [KcAppConfig].
|
||||||
|
///
|
||||||
|
/// In **fake** mode the in-memory fakes are used unconditionally.
|
||||||
|
///
|
||||||
|
/// In **wordpress** mode the WooCommerce credentials are validated first.
|
||||||
|
/// If any credential is missing the app falls back to fake mode and logs a
|
||||||
|
/// warning so the developer knows what went wrong.
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// final factory = KcServiceFactory<WebAppServices>(
|
||||||
|
/// createFake: () => WebAppServices.fake(),
|
||||||
|
/// createWordPress: (config) => WebAppServices.wordpress(...),
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// final (:services, config: effectiveConfig) = KcBootstrap.run(config, factory);
|
||||||
|
/// ```
|
||||||
|
class KcBootstrap {
|
||||||
|
const KcBootstrap._();
|
||||||
|
|
||||||
|
/// Creates the appropriate services for the given [config] using [factory].
|
||||||
|
///
|
||||||
|
/// 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 ({T services, KcAppConfig config}) run<T extends KcAppServices>(
|
||||||
|
KcAppConfig config,
|
||||||
|
KcServiceFactory<T> factory,
|
||||||
|
) {
|
||||||
|
switch (config.environment) {
|
||||||
|
case KcAppEnvironment.fake:
|
||||||
|
return (services: factory.createFake(), config: config);
|
||||||
|
|
||||||
|
case KcAppEnvironment.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 = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: config.wcSiteUrl,
|
||||||
|
wcConsumerKey: config.wcConsumerKey,
|
||||||
|
wcConsumerSecret: config.wcConsumerSecret,
|
||||||
|
);
|
||||||
|
return (services: factory.createFake(), config: fallbackConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (services: factory.createWordPress(config), config: config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,297 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:core/core.dart';
|
import 'package:core/core.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('adds one to input values', () {
|
group('KcAppConfig', () {
|
||||||
final calculator = Calculator();
|
test('creates config with required fields', () {
|
||||||
expect(calculator.addOne(2), 3);
|
const config = KcAppConfig(
|
||||||
expect(calculator.addOne(-7), -6);
|
environment: KcAppEnvironment.fake,
|
||||||
expect(calculator.addOne(0), 1);
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.environment, KcAppEnvironment.fake);
|
||||||
|
expect(config.environmentLabel, 'FAKE');
|
||||||
|
expect(config.hasWordPressConfig, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasWordPressConfig returns true when all credentials present', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: 'https://example.com',
|
||||||
|
wcConsumerKey: 'ck_test',
|
||||||
|
wcConsumerSecret: 'cs_test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.hasWordPressConfig, isTrue);
|
||||||
|
expect(config.environmentLabel, 'WP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasWordPressConfig returns false when any credential is missing', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: 'https://example.com',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: 'cs_test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.hasWordPressConfig, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equality compares all fields', () {
|
||||||
|
const a = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
const b = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
const c = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(a, equals(b));
|
||||||
|
expect(a, isNot(equals(c)));
|
||||||
|
expect(a.hashCode, equals(b.hashCode));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes environment and hasWordPressConfig', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.toString(), contains('fake'));
|
||||||
|
expect(config.toString(), contains('hasWordPressConfig: false'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('KcAppEnvironment', () {
|
||||||
|
test('fromString parses known values', () {
|
||||||
|
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
||||||
|
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromString is case-insensitive', () {
|
||||||
|
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
||||||
|
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromString defaults to fake for unknown values', () {
|
||||||
|
expect(KcAppEnvironment.fromString('unknown'), KcAppEnvironment.fake);
|
||||||
|
expect(KcAppEnvironment.fromString(''), KcAppEnvironment.fake);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label returns expected display string', () {
|
||||||
|
expect(KcAppEnvironment.fake.label, 'FAKE');
|
||||||
|
expect(KcAppEnvironment.wordpress.label, 'WP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('key returns expected raw string', () {
|
||||||
|
expect(KcAppEnvironment.fake.key, 'fake');
|
||||||
|
expect(KcAppEnvironment.wordpress.key, 'wordpress');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('KcAppServices', () {
|
||||||
|
test('abstract base can be extended', () {
|
||||||
|
final services = _TestAppServices();
|
||||||
|
expect(services, isA<KcAppServices>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('KcServiceFactory', () {
|
||||||
|
test('createFake returns fake services', () {
|
||||||
|
final factory = KcServiceFactory<_TestAppServices>(
|
||||||
|
createFake: () => _TestAppServices(mode: 'fake'),
|
||||||
|
createWordPress: (config) => _TestAppServices(mode: 'wp'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final services = factory.createFake();
|
||||||
|
expect(services.mode, 'fake');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createWordPress returns wordpress services', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: 'https://example.com',
|
||||||
|
wcConsumerKey: 'ck_test',
|
||||||
|
wcConsumerSecret: 'cs_test',
|
||||||
|
);
|
||||||
|
final factory = KcServiceFactory<_TestAppServices>(
|
||||||
|
createFake: () => _TestAppServices(mode: 'fake'),
|
||||||
|
createWordPress: (cfg) => _TestAppServices(mode: 'wp-${cfg.wcSiteUrl}'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final services = factory.createWordPress(config);
|
||||||
|
expect(services.mode, 'wp-https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('KcBootstrap', () {
|
||||||
|
late KcServiceFactory<_TestAppServices> factory;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
factory = KcServiceFactory<_TestAppServices>(
|
||||||
|
createFake: () => _TestAppServices(mode: 'fake'),
|
||||||
|
createWordPress: (config) => _TestAppServices(mode: 'wp'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fake environment returns fake services', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = KcBootstrap.run(config, factory);
|
||||||
|
|
||||||
|
expect(result.services.mode, 'fake');
|
||||||
|
expect(result.config.environment, KcAppEnvironment.fake);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wordpress environment with valid credentials returns wp services', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: 'https://example.com',
|
||||||
|
wcConsumerKey: 'ck_test',
|
||||||
|
wcConsumerSecret: 'cs_test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = KcBootstrap.run(config, factory);
|
||||||
|
|
||||||
|
expect(result.services.mode, 'wp');
|
||||||
|
expect(result.config.environment, KcAppEnvironment.wordpress);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wordpress environment without credentials falls back to fake', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = KcBootstrap.run(config, factory);
|
||||||
|
|
||||||
|
expect(result.services.mode, 'fake');
|
||||||
|
expect(result.config.environment, KcAppEnvironment.fake);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wordpress fallback preserves partial credentials in config', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.wordpress,
|
||||||
|
wcSiteUrl: 'https://example.com',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = KcBootstrap.run(config, factory);
|
||||||
|
|
||||||
|
expect(result.services.mode, 'fake');
|
||||||
|
expect(result.config.environment, KcAppEnvironment.fake);
|
||||||
|
expect(result.config.wcSiteUrl, 'https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('KcAppScope', () {
|
||||||
|
testWidgets('of<T> retrieves typed services from widget tree', (tester) async {
|
||||||
|
final services = _TestAppServices(mode: 'test');
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
late _TestAppServices retrievedServices;
|
||||||
|
late KcAppConfig retrievedConfig;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
KcAppScope<_TestAppServices>(
|
||||||
|
services: services,
|
||||||
|
config: config,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
retrievedServices = KcAppScope.of<_TestAppServices>(context);
|
||||||
|
retrievedConfig = KcAppScope.configOf<_TestAppServices>(context);
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(retrievedServices.mode, 'test');
|
||||||
|
expect(retrievedConfig.environment, KcAppEnvironment.fake);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('updateShouldNotify returns true when services change', (tester) async {
|
||||||
|
final scope1 = KcAppScope<_TestAppServices>(
|
||||||
|
services: _TestAppServices(mode: 'a'),
|
||||||
|
config: const KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
),
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
final scope2 = KcAppScope<_TestAppServices>(
|
||||||
|
services: _TestAppServices(mode: 'b'),
|
||||||
|
config: const KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
),
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scope1.updateShouldNotify(scope2), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('updateShouldNotify returns false when identical', (tester) async {
|
||||||
|
final services = _TestAppServices(mode: 'same');
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final scope1 = KcAppScope<_TestAppServices>(
|
||||||
|
services: services,
|
||||||
|
config: config,
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
final scope2 = KcAppScope<_TestAppServices>(
|
||||||
|
services: services,
|
||||||
|
config: config,
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scope1.updateShouldNotify(scope2), isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A minimal concrete [KcAppServices] subclass for testing.
|
||||||
|
class _TestAppServices extends KcAppServices {
|
||||||
|
final String mode;
|
||||||
|
const _TestAppServices({this.mode = 'default'});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
// Theme
|
||||||
export 'src/theme/kc_colors.dart';
|
export 'src/theme/kc_colors.dart';
|
||||||
export 'src/theme/kc_spacing.dart';
|
export 'src/theme/kc_spacing.dart';
|
||||||
export 'src/theme/kc_theme.dart';
|
export 'src/theme/kc_theme.dart';
|
||||||
|
export 'src/theme/kc_typography.dart';
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
export 'src/layout/kc_breakpoints.dart';
|
||||||
|
|
||||||
|
// Widgets
|
||||||
export 'src/widgets/kc_card.dart';
|
export 'src/widgets/kc_card.dart';
|
||||||
|
export 'src/widgets/kc_empty_state.dart';
|
||||||
|
export 'src/widgets/kc_error_state.dart';
|
||||||
|
export 'src/widgets/kc_loading_state.dart';
|
||||||
|
export 'src/widgets/kc_section_header.dart';
|
||||||
export 'src/widgets/kc_status_chip.dart';
|
export 'src/widgets/kc_status_chip.dart';
|
||||||
|
export 'src/widgets/kc_summary_card.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/// Responsive layout breakpoint utilities for Kell Creations.
|
||||||
|
///
|
||||||
|
/// Provides named breakpoints and helper methods for building responsive
|
||||||
|
/// layouts that adapt between mobile, tablet, and desktop form factors.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// if (KcBreakpoints.isDesktop(MediaQuery.of(context).size.width)) {
|
||||||
|
/// // desktop layout
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
abstract final class KcBreakpoints {
|
||||||
|
/// Maximum width considered "compact" (phone portrait).
|
||||||
|
static const double compact = 600;
|
||||||
|
|
||||||
|
/// Maximum width considered "medium" (tablet / phone landscape).
|
||||||
|
static const double medium = 900;
|
||||||
|
|
||||||
|
/// Maximum width considered "expanded" (small desktop / large tablet).
|
||||||
|
static const double expanded = 1200;
|
||||||
|
|
||||||
|
/// Anything wider than [expanded] is "large" (full desktop).
|
||||||
|
|
||||||
|
// ── Named queries ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// True when the available width is below the compact breakpoint.
|
||||||
|
static bool isCompact(double width) => width < compact;
|
||||||
|
|
||||||
|
/// True when the available width is at least compact but below medium.
|
||||||
|
static bool isMedium(double width) => width >= compact && width < medium;
|
||||||
|
|
||||||
|
/// True when the available width is at least medium but below expanded.
|
||||||
|
static bool isExpanded(double width) => width >= medium && width < expanded;
|
||||||
|
|
||||||
|
/// True when the available width is at or above the expanded breakpoint.
|
||||||
|
static bool isLarge(double width) => width >= expanded;
|
||||||
|
|
||||||
|
/// True when the width is at least [medium] (tablet-and-up).
|
||||||
|
static bool isTabletOrLarger(double width) => width >= medium;
|
||||||
|
|
||||||
|
/// True when the width is at least [compact] but below [medium].
|
||||||
|
static bool isTablet(double width) => isMedium(width);
|
||||||
|
|
||||||
|
/// True when the width is below [compact] (phone).
|
||||||
|
static bool isMobile(double width) => isCompact(width);
|
||||||
|
|
||||||
|
/// True when the width is at least [medium] (desktop-class).
|
||||||
|
static bool isDesktop(double width) => width >= medium;
|
||||||
|
|
||||||
|
// ── Grid helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns a suggested grid cross-axis count based on the available [width].
|
||||||
|
///
|
||||||
|
/// Useful for GridView layouts that adapt column count to screen size.
|
||||||
|
static int gridColumns(double width) {
|
||||||
|
if (width >= expanded) return 4;
|
||||||
|
if (width >= medium) return 3;
|
||||||
|
if (width >= compact) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'kc_colors.dart';
|
import 'kc_colors.dart';
|
||||||
|
import 'kc_typography.dart';
|
||||||
|
|
||||||
ThemeData buildKcTheme() {
|
ThemeData buildKcTheme() {
|
||||||
final base = ThemeData(useMaterial3: true);
|
final base = ThemeData(useMaterial3: true);
|
||||||
|
|
@ -18,30 +20,7 @@ ThemeData buildKcTheme() {
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
),
|
),
|
||||||
cardTheme: const CardThemeData(
|
cardTheme: const CardThemeData(color: KcColors.surface, elevation: 0, margin: EdgeInsets.zero),
|
||||||
color: KcColors.surface,
|
textTheme: KcTypography.applyKcTypography(base.textTheme),
|
||||||
elevation: 0,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
textTheme: base.textTheme.copyWith(
|
|
||||||
headlineMedium: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
titleLarge: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
bodyLarge: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
bodyMedium: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: KcColors.deepTeal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'kc_colors.dart';
|
||||||
|
|
||||||
|
/// Shared typography scale for Kell Creations design system.
|
||||||
|
///
|
||||||
|
/// Provides a consistent text style hierarchy across web and mobile
|
||||||
|
/// applications. Use [applyKcTypography] to merge these styles into
|
||||||
|
/// a [TextTheme], or reference individual styles directly.
|
||||||
|
abstract final class KcTypography {
|
||||||
|
// ── Display ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const displayLarge = TextStyle(
|
||||||
|
fontSize: 57,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: -0.25,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const displayMedium = TextStyle(
|
||||||
|
fontSize: 45,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const displaySmall = TextStyle(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Headline ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const headlineLarge = TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const headlineMedium = TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const headlineSmall = TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Title ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const titleLarge = TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const titleMedium = TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const titleSmall = TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Body ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const bodyLarge = TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const bodyMedium = TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const bodySmall = TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Label ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const labelLarge = TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const labelMedium = TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const labelSmall = TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Applies the Kell Creations typography scale to the given [base]
|
||||||
|
/// text theme (or a default one if omitted).
|
||||||
|
static TextTheme applyKcTypography([TextTheme? base]) {
|
||||||
|
return (base ?? const TextTheme()).copyWith(
|
||||||
|
displayLarge: KcTypography.displayLarge,
|
||||||
|
displayMedium: KcTypography.displayMedium,
|
||||||
|
displaySmall: KcTypography.displaySmall,
|
||||||
|
headlineLarge: KcTypography.headlineLarge,
|
||||||
|
headlineMedium: KcTypography.headlineMedium,
|
||||||
|
headlineSmall: KcTypography.headlineSmall,
|
||||||
|
titleLarge: KcTypography.titleLarge,
|
||||||
|
titleMedium: KcTypography.titleMedium,
|
||||||
|
titleSmall: KcTypography.titleSmall,
|
||||||
|
bodyLarge: KcTypography.bodyLarge,
|
||||||
|
bodyMedium: KcTypography.bodyMedium,
|
||||||
|
bodySmall: KcTypography.bodySmall,
|
||||||
|
labelLarge: KcTypography.labelLarge,
|
||||||
|
labelMedium: KcTypography.labelMedium,
|
||||||
|
labelSmall: KcTypography.labelSmall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/kc_colors.dart';
|
||||||
|
import '../theme/kc_spacing.dart';
|
||||||
|
import 'kc_card.dart';
|
||||||
|
|
||||||
|
/// A reusable empty-state panel shown when a section has no data yet.
|
||||||
|
///
|
||||||
|
/// Displays an [icon], a [message], and an optional [action] widget
|
||||||
|
/// (e.g. a button to create the first item). Wraps content in a [KcCard].
|
||||||
|
class KcEmptyState extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String message;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
const KcEmptyState({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!],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/kc_colors.dart';
|
||||||
|
import '../theme/kc_spacing.dart';
|
||||||
|
import 'kc_card.dart';
|
||||||
|
|
||||||
|
/// A shared error-state widget for use across Kell Creations apps.
|
||||||
|
///
|
||||||
|
/// Displays a warning icon, an error [message], and an optional [onRetry]
|
||||||
|
/// action button. Wraps content in a [KcCard] for visual consistency.
|
||||||
|
class KcErrorState extends StatelessWidget {
|
||||||
|
/// The error message to display.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Optional retry callback. When provided, a "Retry" button is shown.
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
const KcErrorState({super.key, required this.message, this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return KcCard(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xl),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: KcColors.danger),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.danger),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (onRetry != null) ...[
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/kc_colors.dart';
|
||||||
|
import '../theme/kc_spacing.dart';
|
||||||
|
|
||||||
|
/// A shared loading-state widget for use across Kell Creations apps.
|
||||||
|
///
|
||||||
|
/// Displays a centered [CircularProgressIndicator] with an optional
|
||||||
|
/// [message] below it. Useful as a placeholder while data is being fetched.
|
||||||
|
class KcLoadingState extends StatelessWidget {
|
||||||
|
/// Optional message displayed below the spinner.
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const KcLoadingState({super.key, this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xl),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
if (message != null) ...[
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
Text(
|
||||||
|
message!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/kc_spacing.dart';
|
||||||
|
|
||||||
|
/// A reusable section header used across app pages.
|
||||||
|
///
|
||||||
|
/// Displays a [title] with an optional trailing [action] widget
|
||||||
|
/// (e.g. a "View all" button).
|
||||||
|
class KcSectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
const KcSectionHeader({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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/kc_colors.dart';
|
||||||
|
import '../theme/kc_spacing.dart';
|
||||||
|
import 'kc_card.dart';
|
||||||
|
|
||||||
|
/// A stat / summary card that displays a [value] with a [label] and an [icon].
|
||||||
|
///
|
||||||
|
/// Used on dashboards to show high-level KPIs such as total products,
|
||||||
|
/// in-stock count, etc. Optionally tappable via [onTap] to navigate to a
|
||||||
|
/// related page.
|
||||||
|
class KcSummaryCard 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 KcSummaryCard({
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
// ── Existing widget tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
group('KcStatusChip', () {
|
group('KcStatusChip', () {
|
||||||
testWidgets('renders label text', (WidgetTester tester) async {
|
testWidgets('renders label text', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -35,5 +37,388 @@ void main() {
|
||||||
final theme = buildKcTheme();
|
final theme = buildKcTheme();
|
||||||
expect(theme, isA<ThemeData>());
|
expect(theme, isA<ThemeData>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('theme uses KcTypography scale', () {
|
||||||
|
final theme = buildKcTheme();
|
||||||
|
expect(theme.textTheme.headlineMedium?.fontSize, 28);
|
||||||
|
expect(theme.textTheme.headlineMedium?.fontWeight, FontWeight.w700);
|
||||||
|
expect(theme.textTheme.titleLarge?.fontSize, 20);
|
||||||
|
expect(theme.textTheme.bodyLarge?.fontSize, 16);
|
||||||
|
expect(theme.textTheme.bodyMedium?.fontSize, 14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcTypography tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcTypography', () {
|
||||||
|
test('displayLarge has expected properties', () {
|
||||||
|
expect(KcTypography.displayLarge.fontSize, 57);
|
||||||
|
expect(KcTypography.displayLarge.fontWeight, FontWeight.w400);
|
||||||
|
expect(KcTypography.displayLarge.color, KcColors.deepTeal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headlineMedium has expected properties', () {
|
||||||
|
expect(KcTypography.headlineMedium.fontSize, 28);
|
||||||
|
expect(KcTypography.headlineMedium.fontWeight, FontWeight.w700);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('titleLarge has expected properties', () {
|
||||||
|
expect(KcTypography.titleLarge.fontSize, 20);
|
||||||
|
expect(KcTypography.titleLarge.fontWeight, FontWeight.w600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bodyLarge has expected properties', () {
|
||||||
|
expect(KcTypography.bodyLarge.fontSize, 16);
|
||||||
|
expect(KcTypography.bodyLarge.fontWeight, FontWeight.w400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bodySmall has expected properties', () {
|
||||||
|
expect(KcTypography.bodySmall.fontSize, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labelSmall has expected properties', () {
|
||||||
|
expect(KcTypography.labelSmall.fontSize, 11);
|
||||||
|
expect(KcTypography.labelSmall.letterSpacing, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyKcTypography produces a complete TextTheme', () {
|
||||||
|
final textTheme = KcTypography.applyKcTypography();
|
||||||
|
expect(textTheme.displayLarge, KcTypography.displayLarge);
|
||||||
|
expect(textTheme.displayMedium, KcTypography.displayMedium);
|
||||||
|
expect(textTheme.displaySmall, KcTypography.displaySmall);
|
||||||
|
expect(textTheme.headlineLarge, KcTypography.headlineLarge);
|
||||||
|
expect(textTheme.headlineMedium, KcTypography.headlineMedium);
|
||||||
|
expect(textTheme.headlineSmall, KcTypography.headlineSmall);
|
||||||
|
expect(textTheme.titleLarge, KcTypography.titleLarge);
|
||||||
|
expect(textTheme.titleMedium, KcTypography.titleMedium);
|
||||||
|
expect(textTheme.titleSmall, KcTypography.titleSmall);
|
||||||
|
expect(textTheme.bodyLarge, KcTypography.bodyLarge);
|
||||||
|
expect(textTheme.bodyMedium, KcTypography.bodyMedium);
|
||||||
|
expect(textTheme.bodySmall, KcTypography.bodySmall);
|
||||||
|
expect(textTheme.labelLarge, KcTypography.labelLarge);
|
||||||
|
expect(textTheme.labelMedium, KcTypography.labelMedium);
|
||||||
|
expect(textTheme.labelSmall, KcTypography.labelSmall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyKcTypography can merge with an existing TextTheme', () {
|
||||||
|
const base = TextTheme(displayLarge: TextStyle(fontSize: 100));
|
||||||
|
final textTheme = KcTypography.applyKcTypography(base);
|
||||||
|
// Should override the base
|
||||||
|
expect(textTheme.displayLarge?.fontSize, 57);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcBreakpoints tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcBreakpoints', () {
|
||||||
|
test('isCompact returns true below 600', () {
|
||||||
|
expect(KcBreakpoints.isCompact(599), true);
|
||||||
|
expect(KcBreakpoints.isCompact(600), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isMedium returns true between 600 and 900', () {
|
||||||
|
expect(KcBreakpoints.isMedium(600), true);
|
||||||
|
expect(KcBreakpoints.isMedium(899), true);
|
||||||
|
expect(KcBreakpoints.isMedium(900), false);
|
||||||
|
expect(KcBreakpoints.isMedium(599), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpanded returns true between 900 and 1200', () {
|
||||||
|
expect(KcBreakpoints.isExpanded(900), true);
|
||||||
|
expect(KcBreakpoints.isExpanded(1199), true);
|
||||||
|
expect(KcBreakpoints.isExpanded(1200), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isLarge returns true at 1200 and above', () {
|
||||||
|
expect(KcBreakpoints.isLarge(1200), true);
|
||||||
|
expect(KcBreakpoints.isLarge(1199), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isMobile returns true below 600', () {
|
||||||
|
expect(KcBreakpoints.isMobile(400), true);
|
||||||
|
expect(KcBreakpoints.isMobile(600), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDesktop returns true at 900 and above', () {
|
||||||
|
expect(KcBreakpoints.isDesktop(900), true);
|
||||||
|
expect(KcBreakpoints.isDesktop(899), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isTabletOrLarger returns true at 900 and above', () {
|
||||||
|
expect(KcBreakpoints.isTabletOrLarger(900), true);
|
||||||
|
expect(KcBreakpoints.isTabletOrLarger(899), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isTablet matches isMedium range', () {
|
||||||
|
expect(KcBreakpoints.isTablet(700), true);
|
||||||
|
expect(KcBreakpoints.isTablet(500), false);
|
||||||
|
expect(KcBreakpoints.isTablet(900), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gridColumns returns correct count for each range', () {
|
||||||
|
expect(KcBreakpoints.gridColumns(400), 1);
|
||||||
|
expect(KcBreakpoints.gridColumns(700), 2);
|
||||||
|
expect(KcBreakpoints.gridColumns(1000), 3);
|
||||||
|
expect(KcBreakpoints.gridColumns(1300), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gridColumns boundary values', () {
|
||||||
|
expect(KcBreakpoints.gridColumns(599), 1);
|
||||||
|
expect(KcBreakpoints.gridColumns(600), 2);
|
||||||
|
expect(KcBreakpoints.gridColumns(899), 2);
|
||||||
|
expect(KcBreakpoints.gridColumns(900), 3);
|
||||||
|
expect(KcBreakpoints.gridColumns(1199), 3);
|
||||||
|
expect(KcBreakpoints.gridColumns(1200), 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcEmptyState tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcEmptyState', () {
|
||||||
|
testWidgets('renders icon and message', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcEmptyState(icon: Icons.inbox, message: 'No items yet'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.inbox), findsOneWidget);
|
||||||
|
expect(find.text('No items yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders optional action widget', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcEmptyState(
|
||||||
|
icon: Icons.inbox,
|
||||||
|
message: 'No items yet',
|
||||||
|
action: ElevatedButton(onPressed: () {}, child: const Text('Add Item')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Add Item'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not render action when null', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcEmptyState(icon: Icons.inbox, message: 'No items yet'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(ElevatedButton), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcErrorState tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcErrorState', () {
|
||||||
|
testWidgets('renders error icon and message', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(body: KcErrorState(message: 'Something went wrong')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
|
expect(find.text('Something went wrong'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders retry button when onRetry is provided', (WidgetTester tester) async {
|
||||||
|
var retried = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcErrorState(message: 'Failed to load', onRetry: () => retried = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Retry'));
|
||||||
|
expect(retried, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not render retry button when onRetry is null', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(body: KcErrorState(message: 'Error occurred')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Retry'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcLoadingState tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcLoadingState', () {
|
||||||
|
testWidgets('renders a CircularProgressIndicator', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: KcLoadingState())));
|
||||||
|
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders optional message', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(body: KcLoadingState(message: 'Loading data...')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Loading data...'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not render message when null', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: KcLoadingState())));
|
||||||
|
|
||||||
|
// Only the progress indicator, no text widget for message.
|
||||||
|
expect(find.byType(Text), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcSectionHeader tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcSectionHeader', () {
|
||||||
|
testWidgets('renders title text', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(body: KcSectionHeader(title: 'My Section')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('My Section'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders optional action widget', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcSectionHeader(
|
||||||
|
title: 'Section',
|
||||||
|
action: TextButton(onPressed: () {}, child: const Text('View All')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('View All'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not render action when null', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(body: KcSectionHeader(title: 'Section')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(TextButton), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcSummaryCard tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcSummaryCard', () {
|
||||||
|
testWidgets('renders icon, label, and value', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcSummaryCard(
|
||||||
|
icon: Icons.inventory_2,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
label: 'Total Products',
|
||||||
|
value: '42',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.inventory_2), findsOneWidget);
|
||||||
|
expect(find.text('Total Products'), findsOneWidget);
|
||||||
|
expect(find.text('42'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is tappable when onTap is provided', (WidgetTester tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcSummaryCard(
|
||||||
|
icon: Icons.inventory_2,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
label: 'Total',
|
||||||
|
value: '10',
|
||||||
|
onTap: () => tapped = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('10'));
|
||||||
|
expect(tapped, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is not tappable when onTap is null', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: KcSummaryCard(
|
||||||
|
icon: Icons.inventory_2,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
label: 'Total',
|
||||||
|
value: '10',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not have a GestureDetector wrapping
|
||||||
|
expect(find.byType(GestureDetector), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcColors tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcColors', () {
|
||||||
|
test('brand colors are defined', () {
|
||||||
|
expect(KcColors.skyBlue, isA<Color>());
|
||||||
|
expect(KcColors.denimBlue, isA<Color>());
|
||||||
|
expect(KcColors.deepTeal, isA<Color>());
|
||||||
|
expect(KcColors.honeyGold, isA<Color>());
|
||||||
|
expect(KcColors.sunsetOrange, isA<Color>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('semantic colors are defined', () {
|
||||||
|
expect(KcColors.success, isA<Color>());
|
||||||
|
expect(KcColors.warning, isA<Color>());
|
||||||
|
expect(KcColors.danger, isA<Color>());
|
||||||
|
expect(KcColors.neutral, isA<Color>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KcSpacing tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('KcSpacing', () {
|
||||||
|
test('spacing scale is ordered correctly', () {
|
||||||
|
expect(KcSpacing.xs, lessThan(KcSpacing.sm));
|
||||||
|
expect(KcSpacing.sm, lessThan(KcSpacing.md));
|
||||||
|
expect(KcSpacing.md, lessThan(KcSpacing.lg));
|
||||||
|
expect(KcSpacing.lg, lessThan(KcSpacing.xl));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spacing values match expected constants', () {
|
||||||
|
expect(KcSpacing.xs, 4.0);
|
||||||
|
expect(KcSpacing.sm, 8.0);
|
||||||
|
expect(KcSpacing.md, 16.0);
|
||||||
|
expect(KcSpacing.lg, 24.0);
|
||||||
|
expect(KcSpacing.xl, 32.0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ export 'src/domain/product_publishing_repository.dart';
|
||||||
export 'src/domain/publish_status.dart';
|
export 'src/domain/publish_status.dart';
|
||||||
|
|
||||||
// Application
|
// Application
|
||||||
export 'src/application/product_publishing_controller.dart' show ProductSortField;
|
export 'src/application/product_publishing_controller.dart' show ListDensity, ProductSortField;
|
||||||
|
export 'src/application/update_product_category.dart';
|
||||||
export 'src/application/update_product_description.dart';
|
export 'src/application/update_product_description.dart';
|
||||||
export 'src/application/update_product_name.dart';
|
export 'src/application/update_product_name.dart';
|
||||||
export 'src/application/update_product_price.dart';
|
export 'src/application/update_product_price.dart';
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,21 @@ import '../domain/product_draft.dart';
|
||||||
import '../domain/publish_status.dart';
|
import '../domain/publish_status.dart';
|
||||||
import 'get_product_drafts.dart';
|
import 'get_product_drafts.dart';
|
||||||
import 'publish_product.dart';
|
import 'publish_product.dart';
|
||||||
|
import 'update_product_category.dart';
|
||||||
import 'update_product_description.dart';
|
import 'update_product_description.dart';
|
||||||
import 'update_product_name.dart';
|
import 'update_product_name.dart';
|
||||||
import 'update_product_price.dart';
|
import 'update_product_price.dart';
|
||||||
import 'update_product_status.dart';
|
import 'update_product_status.dart';
|
||||||
|
|
||||||
|
/// The display density for the product list.
|
||||||
|
enum ListDensity {
|
||||||
|
/// Standard card height with full metadata.
|
||||||
|
standard,
|
||||||
|
|
||||||
|
/// Compact card height with condensed metadata for faster scanning.
|
||||||
|
compact,
|
||||||
|
}
|
||||||
|
|
||||||
/// The field used to sort the visible product list.
|
/// The field used to sort the visible product list.
|
||||||
enum ProductSortField {
|
enum ProductSortField {
|
||||||
/// Sort alphabetically by product name.
|
/// Sort alphabetically by product name.
|
||||||
|
|
@ -87,6 +97,23 @@ class DescriptionActionResult {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The outcome of a category-update action.
|
||||||
|
///
|
||||||
|
/// Consumed once by the UI to show a SnackBar, then cleared.
|
||||||
|
class CategoryActionResult {
|
||||||
|
final bool success;
|
||||||
|
final String productName;
|
||||||
|
final String newCategory;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const CategoryActionResult({
|
||||||
|
required this.success,
|
||||||
|
required this.productName,
|
||||||
|
required this.newCategory,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Controller that manages the product publishing workspace state, including
|
/// Controller that manages the product publishing workspace state, including
|
||||||
/// filtering by publish status, free-text search, and draft selection.
|
/// filtering by publish status, free-text search, and draft selection.
|
||||||
class ProductPublishingController extends ChangeNotifier {
|
class ProductPublishingController extends ChangeNotifier {
|
||||||
|
|
@ -96,6 +123,7 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
final UpdateProductPrice _updateProductPrice;
|
final UpdateProductPrice _updateProductPrice;
|
||||||
final UpdateProductName _updateProductName;
|
final UpdateProductName _updateProductName;
|
||||||
final UpdateProductDescription _updateProductDescription;
|
final UpdateProductDescription _updateProductDescription;
|
||||||
|
final UpdateProductCategory _updateProductCategory;
|
||||||
|
|
||||||
ProductPublishingController(
|
ProductPublishingController(
|
||||||
this._getProductDrafts,
|
this._getProductDrafts,
|
||||||
|
|
@ -104,6 +132,7 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
this._updateProductPrice,
|
this._updateProductPrice,
|
||||||
this._updateProductName,
|
this._updateProductName,
|
||||||
this._updateProductDescription,
|
this._updateProductDescription,
|
||||||
|
this._updateProductCategory,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
@ -137,6 +166,115 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
/// Whether the current sort is ascending (`true`) or descending (`false`).
|
/// Whether the current sort is ascending (`true`) or descending (`false`).
|
||||||
bool sortAscending = true;
|
bool sortAscending = true;
|
||||||
|
|
||||||
|
/// The current display density for the product list.
|
||||||
|
ListDensity listDensity = ListDensity.standard;
|
||||||
|
|
||||||
|
/// The number of days after which a product is considered stale.
|
||||||
|
///
|
||||||
|
/// Products whose [ProductDraft.lastModified] is older than this many days
|
||||||
|
/// from [DateTime.now] are flagged with a visual indicator.
|
||||||
|
static const int staleDays = 30;
|
||||||
|
|
||||||
|
/// Product IDs that the operator has selected for potential bulk actions.
|
||||||
|
///
|
||||||
|
/// Multi-select is independent of the single-item [selectedDraft] preview.
|
||||||
|
/// Adding or removing items here does not change which product is shown
|
||||||
|
/// in the detail panel.
|
||||||
|
final Set<String> multiSelectedIds = {};
|
||||||
|
|
||||||
|
/// The number of products currently multi-selected.
|
||||||
|
int get multiSelectedCount => multiSelectedIds.length;
|
||||||
|
|
||||||
|
/// Whether multi-select mode is active (at least one item selected).
|
||||||
|
bool get isMultiSelectActive => multiSelectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
/// Whether the product with [id] is part of the multi-selection.
|
||||||
|
bool isMultiSelected(String id) => multiSelectedIds.contains(id);
|
||||||
|
|
||||||
|
/// Toggles the multi-select state of the product with [id].
|
||||||
|
///
|
||||||
|
/// If the product is already selected it is removed; otherwise it is added.
|
||||||
|
void toggleMultiSelect(String id) {
|
||||||
|
if (multiSelectedIds.contains(id)) {
|
||||||
|
multiSelectedIds.remove(id);
|
||||||
|
} else {
|
||||||
|
multiSelectedIds.add(id);
|
||||||
|
}
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects all currently visible products (those in [drafts]).
|
||||||
|
void selectAllVisible() {
|
||||||
|
multiSelectedIds.addAll(drafts.map((d) => d.id));
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the entire multi-selection.
|
||||||
|
void clearMultiSelection() {
|
||||||
|
multiSelectedIds.clear();
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the list density between [ListDensity.standard] and
|
||||||
|
/// [ListDensity.compact].
|
||||||
|
void toggleListDensity() {
|
||||||
|
listDensity = listDensity == ListDensity.standard ? ListDensity.compact : ListDensity.standard;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the given [draft] is stale (not modified within [staleDays]).
|
||||||
|
///
|
||||||
|
/// Accepts an optional [now] parameter for testability; defaults to
|
||||||
|
/// [DateTime.now].
|
||||||
|
bool isStale(ProductDraft draft, {DateTime? now}) {
|
||||||
|
final reference = now ?? DateTime.now();
|
||||||
|
return reference.difference(draft.lastModified).inDays >= staleDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of currently visible products that are stale.
|
||||||
|
///
|
||||||
|
/// Accepts an optional [now] parameter for testability.
|
||||||
|
int staleCount({DateTime? now}) {
|
||||||
|
final reference = now ?? DateTime.now();
|
||||||
|
return drafts.where((d) => isStale(d, now: reference)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the next draft in the visible list relative to the current
|
||||||
|
/// [selectedDraft]. Wraps to the first item at the end of the list.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the selection changed.
|
||||||
|
bool selectNextDraft() {
|
||||||
|
if (drafts.isEmpty) return false;
|
||||||
|
if (selectedDraft == null) {
|
||||||
|
selectedDraft = drafts.first;
|
||||||
|
_safeNotify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final currentIndex = drafts.indexWhere((d) => d.id == selectedDraft!.id);
|
||||||
|
final nextIndex = (currentIndex + 1) % drafts.length;
|
||||||
|
selectedDraft = drafts[nextIndex];
|
||||||
|
_safeNotify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the previous draft in the visible list relative to the current
|
||||||
|
/// [selectedDraft]. Wraps to the last item at the beginning of the list.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the selection changed.
|
||||||
|
bool selectPreviousDraft() {
|
||||||
|
if (drafts.isEmpty) return false;
|
||||||
|
if (selectedDraft == null) {
|
||||||
|
selectedDraft = drafts.last;
|
||||||
|
_safeNotify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final currentIndex = drafts.indexWhere((d) => d.id == selectedDraft!.id);
|
||||||
|
final prevIndex = (currentIndex - 1 + drafts.length) % drafts.length;
|
||||||
|
selectedDraft = drafts[prevIndex];
|
||||||
|
_safeNotify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Product IDs that currently have an in-flight status update.
|
/// Product IDs that currently have an in-flight status update.
|
||||||
///
|
///
|
||||||
/// Used to prevent duplicate clicks and to let the UI show per-row
|
/// Used to prevent duplicate clicks and to let the UI show per-row
|
||||||
|
|
@ -174,6 +312,13 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
/// to clear it.
|
/// to clear it.
|
||||||
DescriptionActionResult? lastDescriptionResult;
|
DescriptionActionResult? lastDescriptionResult;
|
||||||
|
|
||||||
|
/// The result of the last category-update action.
|
||||||
|
///
|
||||||
|
/// Set after [updateCategory] completes. The UI should read this once to
|
||||||
|
/// show feedback (e.g. a SnackBar) and then call [consumeCategoryResult]
|
||||||
|
/// to clear it.
|
||||||
|
CategoryActionResult? lastCategoryResult;
|
||||||
|
|
||||||
/// Clears [lastActionResult] so the same result is not shown twice.
|
/// Clears [lastActionResult] so the same result is not shown twice.
|
||||||
void consumeActionResult() {
|
void consumeActionResult() {
|
||||||
lastActionResult = null;
|
lastActionResult = null;
|
||||||
|
|
@ -194,6 +339,11 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
lastDescriptionResult = null;
|
lastDescriptionResult = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears [lastCategoryResult] so the same result is not shown twice.
|
||||||
|
void consumeCategoryResult() {
|
||||||
|
lastCategoryResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads all product drafts and applies any current filter / search.
|
/// Loads all product drafts and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
@ -415,6 +565,40 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates only the category of the product with [id].
|
||||||
|
///
|
||||||
|
/// Follows the same per-row updating pattern as [updateStatus].
|
||||||
|
Future<void> updateCategory(String id, String category) async {
|
||||||
|
if (updatingIds.contains(id)) return;
|
||||||
|
|
||||||
|
final productName = _productNameById(id);
|
||||||
|
|
||||||
|
updatingIds.add(id);
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _updateProductCategory(id, category);
|
||||||
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastCategoryResult = CategoryActionResult(
|
||||||
|
success: true,
|
||||||
|
productName: productName,
|
||||||
|
newCategory: category,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastCategoryResult = CategoryActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: productName,
|
||||||
|
newCategory: category,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lifecycle ───────────────────────────────────────────────────────────
|
// ── Lifecycle ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -468,9 +652,10 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
|
|
||||||
drafts = result;
|
drafts = result;
|
||||||
|
|
||||||
// Keep selection valid; clear if the selected draft is no longer visible.
|
// Keep selection valid and refresh to latest values.
|
||||||
if (selectedDraft != null && !drafts.any((d) => d.id == selectedDraft!.id)) {
|
if (selectedDraft != null) {
|
||||||
selectedDraft = null;
|
final refreshed = drafts.where((d) => d.id == selectedDraft!.id).firstOrNull;
|
||||||
|
selectedDraft = refreshed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import '../domain/product_draft.dart';
|
||||||
|
import '../domain/product_publishing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case: update only the category of a single product by its [id].
|
||||||
|
///
|
||||||
|
/// This is a narrow category mutation — not a generic product edit.
|
||||||
|
class UpdateProductCategory {
|
||||||
|
final ProductPublishingRepository repository;
|
||||||
|
|
||||||
|
UpdateProductCategory(this.repository);
|
||||||
|
|
||||||
|
Future<ProductDraft> call(String id, String category) =>
|
||||||
|
repository.updateProductCategory(id, category);
|
||||||
|
}
|
||||||
|
|
@ -170,4 +170,19 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
|
||||||
_drafts[index] = updated;
|
_drafts[index] = updated;
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductCategory(String id, String category) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
final index = _drafts.indexWhere((d) => d.id == id);
|
||||||
|
if (index == -1) {
|
||||||
|
throw StateError('Draft with id $id not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final original = _drafts[index];
|
||||||
|
final updated = original.copyWith(category: category, lastModified: DateTime.now());
|
||||||
|
_drafts[index] = updated;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,14 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
|
||||||
final json = await _apiClient.updateProduct(id, {'description': description});
|
final json = await _apiClient.updateProduct(id, {'description': description});
|
||||||
return _mapper.fromJson(json);
|
return _mapper.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductCategory(String id, String category) async {
|
||||||
|
final json = await _apiClient.updateProduct(id, {
|
||||||
|
'categories': [
|
||||||
|
{'name': category},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return _mapper.fromJson(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,9 @@ abstract class ProductPublishingRepository {
|
||||||
///
|
///
|
||||||
/// Returns the updated [ProductDraft] reflecting the new description.
|
/// Returns the updated [ProductDraft] reflecting the new description.
|
||||||
Future<ProductDraft> updateProductDescription(String id, String description);
|
Future<ProductDraft> updateProductDescription(String id, String description);
|
||||||
|
|
||||||
|
/// Updates only the category of the product identified by [id].
|
||||||
|
///
|
||||||
|
/// Returns the updated [ProductDraft] reflecting the new category.
|
||||||
|
Future<ProductDraft> updateProductCategory(String id, String category);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../application/get_product_drafts.dart';
|
import '../application/get_product_drafts.dart';
|
||||||
import '../application/product_publishing_controller.dart';
|
import '../application/product_publishing_controller.dart';
|
||||||
import '../application/publish_product.dart';
|
import '../application/publish_product.dart';
|
||||||
|
import '../application/update_product_category.dart';
|
||||||
import '../application/update_product_description.dart';
|
import '../application/update_product_description.dart';
|
||||||
import '../application/update_product_name.dart';
|
import '../application/update_product_name.dart';
|
||||||
import '../application/update_product_price.dart';
|
import '../application/update_product_price.dart';
|
||||||
|
|
@ -49,10 +51,13 @@ class ProductPublishingPage extends StatefulWidget {
|
||||||
|
|
||||||
class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
late final ProductPublishingController controller;
|
late final ProductPublishingController controller;
|
||||||
|
late final FocusNode _listFocusNode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_listFocusNode = FocusNode();
|
||||||
|
|
||||||
final repo = widget.repository;
|
final repo = widget.repository;
|
||||||
controller = ProductPublishingController(
|
controller = ProductPublishingController(
|
||||||
GetProductDrafts(repo),
|
GetProductDrafts(repo),
|
||||||
|
|
@ -61,6 +66,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
UpdateProductPrice(repo),
|
UpdateProductPrice(repo),
|
||||||
UpdateProductName(repo),
|
UpdateProductName(repo),
|
||||||
UpdateProductDescription(repo),
|
UpdateProductDescription(repo),
|
||||||
|
UpdateProductCategory(repo),
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.addListener(_onControllerChanged);
|
controller.addListener(_onControllerChanged);
|
||||||
|
|
@ -85,6 +91,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
controller.removeListener(_onControllerChanged);
|
controller.removeListener(_onControllerChanged);
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
|
_listFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +119,30 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
controller.consumeDescriptionResult();
|
controller.consumeDescriptionResult();
|
||||||
showDescriptionActionSnackBar(context, descriptionResult);
|
showDescriptionActionSnackBar(context, descriptionResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final categoryResult = controller.lastCategoryResult;
|
||||||
|
if (categoryResult != null) {
|
||||||
|
controller.consumeCategoryResult();
|
||||||
|
showCategoryActionSnackBar(context, categoryResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles keyboard events for list navigation.
|
||||||
|
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||||
|
controller.selectNextDraft();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||||
|
controller.selectPreviousDraft();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -174,6 +205,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
|
|
||||||
Widget _buildDraftList() {
|
Widget _buildDraftList() {
|
||||||
final count = controller.drafts.length;
|
final count = controller.drafts.length;
|
||||||
|
final isCompact = controller.listDensity == ListDensity.compact;
|
||||||
|
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
return Center(
|
return Center(
|
||||||
|
|
@ -193,34 +225,65 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Focus(
|
||||||
|
focusNode: _listFocusNode,
|
||||||
|
onKeyEvent: _handleKeyEvent,
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// ── Multi-select action bar ──────────────────────────────────
|
||||||
|
if (controller.isMultiSelectActive)
|
||||||
|
_MultiSelectBar(
|
||||||
|
selectedCount: controller.multiSelectedCount,
|
||||||
|
totalCount: count,
|
||||||
|
onSelectAll: controller.selectAllVisible,
|
||||||
|
onClearSelection: controller.clearMultiSelection,
|
||||||
|
),
|
||||||
|
// ── List header with count and density toggle ───────────────
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||||
child: Text(
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
'$count ${count == 1 ? 'product' : 'products'}',
|
'$count ${count == 1 ? 'product' : 'products'}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Tooltip(
|
||||||
|
message: isCompact ? 'Standard view' : 'Compact view',
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(isCompact ? Icons.view_agenda : Icons.view_headline, size: 20),
|
||||||
|
onPressed: controller.toggleListDensity,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: count,
|
itemCount: count,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
separatorBuilder: (_, _) => SizedBox(height: isCompact ? KcSpacing.xs : KcSpacing.sm),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final draft = controller.drafts[index];
|
final draft = controller.drafts[index];
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 160,
|
height: isCompact ? 72 : 180,
|
||||||
child: ProductDraftCard(
|
child: ProductDraftCard(
|
||||||
draft: draft,
|
draft: draft,
|
||||||
isSelected: draft.id == controller.selectedDraft?.id,
|
isSelected: draft.id == controller.selectedDraft?.id,
|
||||||
onTap: () => controller.selectDraft(draft),
|
onTap: () => controller.selectDraft(draft),
|
||||||
|
isMultiSelected: controller.isMultiSelected(draft.id),
|
||||||
|
onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id),
|
||||||
|
isCompact: isCompact,
|
||||||
|
isStale: controller.isStale(draft),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +312,58 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
onPriceChanged: (price) => controller.updatePrice(selected.id, price),
|
onPriceChanged: (price) => controller.updatePrice(selected.id, price),
|
||||||
onNameChanged: (name) => controller.updateName(selected.id, name),
|
onNameChanged: (name) => controller.updateName(selected.id, name),
|
||||||
onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc),
|
onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc),
|
||||||
|
onCategoryChanged: (cat) => controller.updateCategory(selected.id, cat),
|
||||||
onViewPolicy: widget.onViewPolicy,
|
onViewPolicy: widget.onViewPolicy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A compact action bar shown above the product list when multi-select is
|
||||||
|
/// active. Displays the count of selected items and provides Select All /
|
||||||
|
/// Clear Selection actions.
|
||||||
|
///
|
||||||
|
/// No bulk-write actions are included — this is groundwork only.
|
||||||
|
class _MultiSelectBar extends StatelessWidget {
|
||||||
|
final int selectedCount;
|
||||||
|
final int totalCount;
|
||||||
|
final VoidCallback onSelectAll;
|
||||||
|
final VoidCallback onClearSelection;
|
||||||
|
|
||||||
|
const _MultiSelectBar({
|
||||||
|
required this.selectedCount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.onSelectAll,
|
||||||
|
required this.onClearSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final allSelected = selectedCount == totalCount;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.sm, vertical: KcSpacing.xs),
|
||||||
|
margin: const EdgeInsets.only(bottom: KcSpacing.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: KcColors.denimBlue.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: KcColors.denimBlue.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.checklist, size: 18, color: KcColors.denimBlue),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
Text(
|
||||||
|
'$selectedCount selected',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: KcColors.denimBlue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')),
|
||||||
|
TextButton(onPressed: onClearSelection, child: const Text('Clear')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,60 +7,138 @@ import 'publish_status_chip.dart';
|
||||||
/// A card displaying a summary of a [ProductDraft].
|
/// A card displaying a summary of a [ProductDraft].
|
||||||
///
|
///
|
||||||
/// Shows the product name, SKU, price, category, and publish status.
|
/// Shows the product name, SKU, price, category, and publish status.
|
||||||
/// Highlights when [isSelected] is true.
|
/// Highlights when [isSelected] is true (single-item preview selection).
|
||||||
|
///
|
||||||
|
/// When [isMultiSelected] is non-null, a leading checkbox is shown to
|
||||||
|
/// support multi-select mode. Tapping the checkbox fires [onMultiSelectToggle];
|
||||||
|
/// tapping the rest of the card still fires [onTap] for single-item preview.
|
||||||
|
///
|
||||||
|
/// When [isCompact] is true, the card uses a denser layout with a smaller
|
||||||
|
/// height and condensed metadata. A [descriptionSnippet] may be shown in
|
||||||
|
/// standard mode for lightweight secondary metadata visibility.
|
||||||
|
///
|
||||||
|
/// When [isStale] is true, a warning icon is displayed to flag products
|
||||||
|
/// that have not been modified recently.
|
||||||
class ProductDraftCard extends StatelessWidget {
|
class ProductDraftCard extends StatelessWidget {
|
||||||
final ProductDraft draft;
|
final ProductDraft draft;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const ProductDraftCard({super.key, required this.draft, this.isSelected = false, this.onTap});
|
/// Whether this card is part of the multi-selection.
|
||||||
|
/// When `null`, the checkbox is hidden (multi-select not active).
|
||||||
|
final bool? isMultiSelected;
|
||||||
|
|
||||||
|
/// Called when the multi-select checkbox is toggled.
|
||||||
|
final VoidCallback? onMultiSelectToggle;
|
||||||
|
|
||||||
|
/// Whether to use the compact (denser) layout.
|
||||||
|
final bool isCompact;
|
||||||
|
|
||||||
|
/// Whether this product is stale (not modified recently).
|
||||||
|
final bool isStale;
|
||||||
|
|
||||||
|
const ProductDraftCard({
|
||||||
|
super.key,
|
||||||
|
required this.draft,
|
||||||
|
this.isSelected = false,
|
||||||
|
this.onTap,
|
||||||
|
this.isMultiSelected,
|
||||||
|
this.onMultiSelectToggle,
|
||||||
|
this.isCompact = false,
|
||||||
|
this.isStale = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final showCheckbox = isMultiSelected != null;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.all(KcSpacing.md),
|
padding: EdgeInsets.all(isCompact ? KcSpacing.sm : KcSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: KcColors.surface,
|
color: KcColors.surface,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? KcColors.denimBlue : KcColors.border,
|
color: isSelected ? KcColors.denimBlue : KcColors.border,
|
||||||
width: isSelected ? 2 : 1,
|
width: isSelected ? 2 : 1,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(isCompact ? 10 : 16),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
if (showCheckbox) ...[
|
||||||
|
Checkbox(
|
||||||
|
value: isMultiSelected ?? false,
|
||||||
|
onChanged: (_) => onMultiSelectToggle?.call(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: isCompact ? _buildCompactContent(context) : _buildStandardContent(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard layout — full metadata with description snippet and spacer.
|
||||||
|
Widget _buildStandardContent(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
draft.name,
|
draft.name,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (isStale) ...[
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Not modified in 30+ days',
|
||||||
|
child: Icon(Icons.schedule, size: 16, color: KcColors.warning),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: KcSpacing.xs),
|
const SizedBox(height: KcSpacing.xs),
|
||||||
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.xs),
|
||||||
|
// Description snippet for lightweight secondary metadata visibility.
|
||||||
|
Text(
|
||||||
|
draft.description,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'\$${draft.price.toStringAsFixed(2)}',
|
'\$${draft.price.toStringAsFixed(2)}',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: KcSpacing.sm),
|
const SizedBox(width: KcSpacing.sm),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
draft.category,
|
draft.category,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: KcSpacing.xs),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -72,8 +150,63 @@ class ProductDraftCard extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact layout — single-row-dense metadata for faster scanning.
|
||||||
|
Widget _buildCompactContent(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (isStale) ...[
|
||||||
|
Tooltip(
|
||||||
|
message: 'Not modified in 30+ days',
|
||||||
|
child: Icon(Icons.schedule, size: 14, color: KcColors.warning),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
draft.name,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
PublishStatusChip(status: draft.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.xs),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(draft.sku, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral)),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
Text(
|
||||||
|
'\$${draft.price.toStringAsFixed(2)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
draft.category,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
Text(
|
||||||
|
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ class ProductPreviewPanel extends StatefulWidget {
|
||||||
/// Callback to update the product description. Receives the new description value.
|
/// Callback to update the product description. Receives the new description value.
|
||||||
final ValueChanged<String>? onDescriptionChanged;
|
final ValueChanged<String>? onDescriptionChanged;
|
||||||
|
|
||||||
|
/// Callback to update the product category. Receives the new category value.
|
||||||
|
final ValueChanged<String>? onCategoryChanged;
|
||||||
|
|
||||||
/// Whether this product currently has an in-flight status update.
|
/// Whether this product currently has an in-flight status update.
|
||||||
/// When true, the action button is disabled and a progress indicator is shown.
|
/// When true, the action button is disabled and a progress indicator is shown.
|
||||||
final bool isUpdating;
|
final bool isUpdating;
|
||||||
|
|
@ -44,6 +47,7 @@ class ProductPreviewPanel extends StatefulWidget {
|
||||||
this.onPriceChanged,
|
this.onPriceChanged,
|
||||||
this.onNameChanged,
|
this.onNameChanged,
|
||||||
this.onDescriptionChanged,
|
this.onDescriptionChanged,
|
||||||
|
this.onCategoryChanged,
|
||||||
this.isUpdating = false,
|
this.isUpdating = false,
|
||||||
this.onViewPolicy,
|
this.onViewPolicy,
|
||||||
});
|
});
|
||||||
|
|
@ -55,12 +59,20 @@ class ProductPreviewPanel extends StatefulWidget {
|
||||||
class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
bool _editingPrice = false;
|
bool _editingPrice = false;
|
||||||
late TextEditingController _priceController;
|
late TextEditingController _priceController;
|
||||||
|
String? _priceError;
|
||||||
|
|
||||||
bool _editingName = false;
|
bool _editingName = false;
|
||||||
late TextEditingController _nameController;
|
late TextEditingController _nameController;
|
||||||
|
String? _nameError;
|
||||||
|
|
||||||
bool _editingDescription = false;
|
bool _editingDescription = false;
|
||||||
late TextEditingController _descriptionController;
|
late TextEditingController _descriptionController;
|
||||||
|
String? _descriptionError;
|
||||||
|
|
||||||
|
// ignore: prefer_final_fields
|
||||||
|
bool _editingCategory = false;
|
||||||
|
late TextEditingController _categoryController;
|
||||||
|
String? _categoryError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -68,6 +80,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
_priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2));
|
_priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2));
|
||||||
_nameController = TextEditingController(text: widget.draft.name);
|
_nameController = TextEditingController(text: widget.draft.name);
|
||||||
_descriptionController = TextEditingController(text: widget.draft.description);
|
_descriptionController = TextEditingController(text: widget.draft.description);
|
||||||
|
_categoryController = TextEditingController(text: widget.draft.category);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -75,17 +88,26 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.price != widget.draft.price) {
|
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.price != widget.draft.price) {
|
||||||
_editingPrice = false;
|
_editingPrice = false;
|
||||||
|
_priceError = null;
|
||||||
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
||||||
}
|
}
|
||||||
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.name != widget.draft.name) {
|
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.name != widget.draft.name) {
|
||||||
_editingName = false;
|
_editingName = false;
|
||||||
|
_nameError = null;
|
||||||
_nameController.text = widget.draft.name;
|
_nameController.text = widget.draft.name;
|
||||||
}
|
}
|
||||||
if (oldWidget.draft.id != widget.draft.id ||
|
if (oldWidget.draft.id != widget.draft.id ||
|
||||||
oldWidget.draft.description != widget.draft.description) {
|
oldWidget.draft.description != widget.draft.description) {
|
||||||
_editingDescription = false;
|
_editingDescription = false;
|
||||||
|
_descriptionError = null;
|
||||||
_descriptionController.text = widget.draft.description;
|
_descriptionController.text = widget.draft.description;
|
||||||
}
|
}
|
||||||
|
if (oldWidget.draft.id != widget.draft.id ||
|
||||||
|
oldWidget.draft.category != widget.draft.category) {
|
||||||
|
_editingCategory = false;
|
||||||
|
_categoryError = null;
|
||||||
|
_categoryController.text = widget.draft.category;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -93,46 +115,92 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
_priceController.dispose();
|
_priceController.dispose();
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
|
_categoryController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submitPrice() {
|
void _submitPrice() {
|
||||||
final parsed = double.tryParse(_priceController.text);
|
final parsed = double.tryParse(_priceController.text);
|
||||||
if (parsed != null && parsed >= 0) {
|
if (parsed == null || parsed < 0) {
|
||||||
widget.onPriceChanged?.call(parsed);
|
setState(() => _priceError = 'Enter a valid price.');
|
||||||
setState(() => _editingPrice = false);
|
return;
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_priceError = null;
|
||||||
|
_editingPrice = false;
|
||||||
|
});
|
||||||
|
widget.onPriceChanged?.call(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancelEdit() {
|
void _cancelEdit() {
|
||||||
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
||||||
setState(() => _editingPrice = false);
|
setState(() {
|
||||||
|
_priceError = null;
|
||||||
|
_editingPrice = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submitName() {
|
void _submitName() {
|
||||||
final trimmed = _nameController.text.trim();
|
final trimmed = _nameController.text.trim();
|
||||||
if (trimmed.isNotEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
widget.onNameChanged?.call(trimmed);
|
setState(() => _nameError = 'Name cannot be empty.');
|
||||||
setState(() => _editingName = false);
|
return;
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_nameError = null;
|
||||||
|
_editingName = false;
|
||||||
|
});
|
||||||
|
widget.onNameChanged?.call(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancelNameEdit() {
|
void _cancelNameEdit() {
|
||||||
_nameController.text = widget.draft.name;
|
_nameController.text = widget.draft.name;
|
||||||
setState(() => _editingName = false);
|
setState(() {
|
||||||
|
_nameError = null;
|
||||||
|
_editingName = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submitDescription() {
|
void _submitDescription() {
|
||||||
final trimmed = _descriptionController.text.trim();
|
final trimmed = _descriptionController.text.trim();
|
||||||
if (trimmed.isNotEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
widget.onDescriptionChanged?.call(trimmed);
|
setState(() => _descriptionError = 'Description cannot be empty.');
|
||||||
setState(() => _editingDescription = false);
|
return;
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_descriptionError = null;
|
||||||
|
_editingDescription = false;
|
||||||
|
});
|
||||||
|
widget.onDescriptionChanged?.call(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancelDescriptionEdit() {
|
void _cancelDescriptionEdit() {
|
||||||
_descriptionController.text = widget.draft.description;
|
_descriptionController.text = widget.draft.description;
|
||||||
setState(() => _editingDescription = false);
|
setState(() {
|
||||||
|
_descriptionError = null;
|
||||||
|
_editingDescription = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitCategory() {
|
||||||
|
final trimmed = _categoryController.text.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
setState(() => _categoryError = 'Category cannot be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_categoryError = null;
|
||||||
|
_editingCategory = false;
|
||||||
|
});
|
||||||
|
widget.onCategoryChanged?.call(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelCategoryEdit() {
|
||||||
|
_categoryController.text = widget.draft.category;
|
||||||
|
setState(() {
|
||||||
|
_categoryError = null;
|
||||||
|
_editingCategory = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -156,7 +224,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
// ── Metadata ───────────────────────────────────────────────
|
// ── Metadata ───────────────────────────────────────────────
|
||||||
_MetadataRow(label: 'SKU', value: draft.sku),
|
_MetadataRow(label: 'SKU', value: draft.sku),
|
||||||
_buildPriceRow(context),
|
_buildPriceRow(context),
|
||||||
_MetadataRow(label: 'Category', value: draft.category),
|
_buildCategoryRow(context),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Last Modified',
|
label: 'Last Modified',
|
||||||
value:
|
value:
|
||||||
|
|
@ -218,15 +286,20 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
final draft = widget.draft;
|
final draft = widget.draft;
|
||||||
|
|
||||||
if (_editingName) {
|
if (_editingName) {
|
||||||
return Row(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
|
enabled: !widget.isUpdating,
|
||||||
style: theme.textTheme.headlineMedium,
|
style: theme.textTheme.headlineMedium,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
errorText: _nameError,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _submitName(),
|
onSubmitted: (_) => _submitName(),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -235,7 +308,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const SizedBox(width: KcSpacing.xs),
|
const SizedBox(width: KcSpacing.xs),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.check, size: 20),
|
icon: const Icon(Icons.check, size: 20),
|
||||||
onPressed: _submitName,
|
onPressed: widget.isUpdating ? null : _submitName,
|
||||||
tooltip: 'Save name',
|
tooltip: 'Save name',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
|
|
@ -243,12 +316,14 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const SizedBox(width: KcSpacing.xs),
|
const SizedBox(width: KcSpacing.xs),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, size: 20),
|
icon: const Icon(Icons.close, size: 20),
|
||||||
onPressed: _cancelNameEdit,
|
onPressed: widget.isUpdating ? null : _cancelNameEdit,
|
||||||
tooltip: 'Cancel',
|
tooltip: 'Cancel',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,7 +355,10 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
if (_editingPrice) {
|
if (_editingPrice) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|
@ -296,12 +374,14 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
width: 100,
|
width: 100,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _priceController,
|
controller: _priceController,
|
||||||
|
enabled: !widget.isUpdating,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixText: '\$ ',
|
prefixText: '\$ ',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
errorText: _priceError,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _submitPrice(),
|
onSubmitted: (_) => _submitPrice(),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -310,7 +390,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const SizedBox(width: KcSpacing.xs),
|
const SizedBox(width: KcSpacing.xs),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.check, size: 20),
|
icon: const Icon(Icons.check, size: 20),
|
||||||
onPressed: _submitPrice,
|
onPressed: widget.isUpdating ? null : _submitPrice,
|
||||||
tooltip: 'Save price',
|
tooltip: 'Save price',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
|
|
@ -318,13 +398,15 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const SizedBox(width: KcSpacing.xs),
|
const SizedBox(width: KcSpacing.xs),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, size: 20),
|
icon: const Icon(Icons.close, size: 20),
|
||||||
onPressed: _cancelEdit,
|
onPressed: widget.isUpdating ? null : _cancelEdit,
|
||||||
tooltip: 'Cancel',
|
tooltip: 'Cancel',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,6 +443,93 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the Category metadata row — either a static display with an edit
|
||||||
|
/// icon, or an inline text field with save/cancel actions.
|
||||||
|
Widget _buildCategoryRow(BuildContext context) {
|
||||||
|
if (_editingCategory) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
'Category',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: KcColors.neutral,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _categoryController,
|
||||||
|
enabled: !widget.isUpdating,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
errorText: _categoryError,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submitCategory(),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.check, size: 20),
|
||||||
|
onPressed: widget.isUpdating ? null : _submitCategory,
|
||||||
|
tooltip: 'Save category',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: widget.isUpdating ? null : _cancelCategoryEdit,
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
'Category',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: KcColors.neutral,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(widget.draft.category, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
if (widget.onCategoryChanged != null && !widget.isUpdating)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
onPressed: () => setState(() => _editingCategory = true),
|
||||||
|
tooltip: 'Edit category',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the Description section — either a static display with an edit
|
/// Builds the Description section — either a static display with an edit
|
||||||
/// icon, or a multi-line text field with save/cancel actions.
|
/// icon, or a multi-line text field with save/cancel actions.
|
||||||
Widget _buildDescriptionSection(BuildContext context) {
|
Widget _buildDescriptionSection(BuildContext context) {
|
||||||
|
|
@ -376,7 +545,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.check, size: 20),
|
icon: const Icon(Icons.check, size: 20),
|
||||||
onPressed: _submitDescription,
|
onPressed: widget.isUpdating ? null : _submitDescription,
|
||||||
tooltip: 'Save description',
|
tooltip: 'Save description',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
|
|
@ -384,7 +553,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const SizedBox(width: KcSpacing.xs),
|
const SizedBox(width: KcSpacing.xs),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, size: 20),
|
icon: const Icon(Icons.close, size: 20),
|
||||||
onPressed: _cancelDescriptionEdit,
|
onPressed: widget.isUpdating ? null : _cancelDescriptionEdit,
|
||||||
tooltip: 'Cancel description edit',
|
tooltip: 'Cancel description edit',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
|
|
@ -394,13 +563,15 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _descriptionController,
|
controller: _descriptionController,
|
||||||
|
enabled: !widget.isUpdating,
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.all(8),
|
contentPadding: const EdgeInsets.all(8),
|
||||||
border: OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _descriptionError,
|
||||||
),
|
),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import 'package:flutter/material.dart';
|
||||||
import '../../application/product_publishing_controller.dart';
|
import '../../application/product_publishing_controller.dart';
|
||||||
import '../../domain/publish_status.dart';
|
import '../../domain/publish_status.dart';
|
||||||
|
|
||||||
|
/// Truncates [text] to [maxLength] characters, appending '…' if truncated.
|
||||||
|
String _truncate(String text, {int maxLength = 60}) {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return '${text.substring(0, maxLength)}…';
|
||||||
|
}
|
||||||
|
|
||||||
/// Shows a [SnackBar] for the given [StatusActionResult].
|
/// Shows a [SnackBar] for the given [StatusActionResult].
|
||||||
///
|
///
|
||||||
/// Uses [KcColors.success] / [KcColors.danger] to match the design system.
|
/// Uses [KcColors.success] / [KcColors.danger] to match the design system.
|
||||||
|
|
@ -23,7 +29,8 @@ void showStatusActionSnackBar(BuildContext context, StatusActionResult result) {
|
||||||
icon = Icons.check_circle_outline;
|
icon = Icons.check_circle_outline;
|
||||||
} else {
|
} else {
|
||||||
final verb = _infinitiveVerbForStatus(result.targetStatus);
|
final verb = _infinitiveVerbForStatus(result.targetStatus);
|
||||||
message = 'Failed to $verb ${result.productName}.';
|
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
|
||||||
|
message = 'Failed to $verb ${result.productName}.$detail';
|
||||||
backgroundColor = KcColors.danger;
|
backgroundColor = KcColors.danger;
|
||||||
icon = Icons.error_outline;
|
icon = Icons.error_outline;
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +62,12 @@ void showDescriptionActionSnackBar(BuildContext context, DescriptionActionResult
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
message = '${result.productName} description updated.';
|
message = '${result.productName} description updated successfully.';
|
||||||
backgroundColor = KcColors.success;
|
backgroundColor = KcColors.success;
|
||||||
icon = Icons.check_circle_outline;
|
icon = Icons.check_circle_outline;
|
||||||
} else {
|
} else {
|
||||||
message = 'Failed to update description for ${result.productName}.';
|
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
|
||||||
|
message = 'Failed to update description for ${result.productName}.$detail';
|
||||||
backgroundColor = KcColors.danger;
|
backgroundColor = KcColors.danger;
|
||||||
icon = Icons.error_outline;
|
icon = Icons.error_outline;
|
||||||
}
|
}
|
||||||
|
|
@ -119,11 +127,50 @@ void showPriceActionSnackBar(BuildContext context, PriceActionResult result) {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
message = '${result.productName} price updated to \$${result.newPrice.toStringAsFixed(2)}.';
|
message =
|
||||||
|
'${result.productName} price updated to \$${result.newPrice.toStringAsFixed(2)} successfully.';
|
||||||
backgroundColor = KcColors.success;
|
backgroundColor = KcColors.success;
|
||||||
icon = Icons.check_circle_outline;
|
icon = Icons.check_circle_outline;
|
||||||
} else {
|
} else {
|
||||||
message = 'Failed to update price for ${result.productName}.';
|
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
|
||||||
|
message = 'Failed to update price for ${result.productName}.$detail';
|
||||||
|
backgroundColor = KcColors.danger;
|
||||||
|
icon = Icons.error_outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: result.success ? const Duration(seconds: 3) : const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a [SnackBar] for the given [CategoryActionResult].
|
||||||
|
void showCategoryActionSnackBar(BuildContext context, CategoryActionResult result) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (messenger == null) return;
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message = '${result.productName} category updated to ${result.newCategory} successfully.';
|
||||||
|
backgroundColor = KcColors.success;
|
||||||
|
icon = Icons.check_circle_outline;
|
||||||
|
} else {
|
||||||
|
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
|
||||||
|
message = 'Failed to update category for ${result.productName}.$detail';
|
||||||
backgroundColor = KcColors.danger;
|
backgroundColor = KcColors.danger;
|
||||||
icon = Icons.error_outline;
|
icon = Icons.error_outline;
|
||||||
}
|
}
|
||||||
|
|
@ -155,11 +202,12 @@ void showNameActionSnackBar(BuildContext context, NameActionResult result) {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
message = 'Product renamed to ${result.newName}.';
|
message = '${result.productName} renamed to ${result.newName} successfully.';
|
||||||
backgroundColor = KcColors.success;
|
backgroundColor = KcColors.success;
|
||||||
icon = Icons.check_circle_outline;
|
icon = Icons.check_circle_outline;
|
||||||
} else {
|
} else {
|
||||||
message = 'Failed to rename ${result.productName}.';
|
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
|
||||||
|
message = 'Failed to rename ${result.productName}.$detail';
|
||||||
backgroundColor = KcColors.danger;
|
backgroundColor = KcColors.danger;
|
||||||
icon = Icons.error_outline;
|
icon = Icons.error_outline;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -299,5 +299,68 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('updateProductCategory', () {
|
||||||
|
test('updates category of existing product', () async {
|
||||||
|
final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4');
|
||||||
|
expect(before.category, 'Kitchen Accessories');
|
||||||
|
|
||||||
|
final updated = await repository.updateProductCategory('4', 'Grippers');
|
||||||
|
expect(updated.category, 'Grippers');
|
||||||
|
expect(updated.id, '4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists category change across subsequent reads', () async {
|
||||||
|
await repository.updateProductCategory('4', 'Grippers');
|
||||||
|
|
||||||
|
final drafts = await repository.getProductDrafts();
|
||||||
|
final product = drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(product.category, 'Grippers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates lastModified timestamp', () async {
|
||||||
|
final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4');
|
||||||
|
|
||||||
|
final updated = await repository.updateProductCategory('4', 'Grippers');
|
||||||
|
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other fields unchanged', () async {
|
||||||
|
final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4');
|
||||||
|
|
||||||
|
final updated = await repository.updateProductCategory('4', 'Grippers');
|
||||||
|
expect(updated.name, before.name);
|
||||||
|
expect(updated.description, before.description);
|
||||||
|
expect(updated.price, before.price);
|
||||||
|
expect(updated.sku, before.sku);
|
||||||
|
expect(updated.imageUrl, before.imageUrl);
|
||||||
|
expect(updated.status, before.status);
|
||||||
|
expect(updated.category, 'Grippers');
|
||||||
|
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other products unchanged', () async {
|
||||||
|
final draftsBefore = await repository.getProductDrafts();
|
||||||
|
|
||||||
|
await repository.updateProductCategory('4', 'Grippers');
|
||||||
|
|
||||||
|
final draftsAfter = await repository.getProductDrafts();
|
||||||
|
for (final before in draftsBefore) {
|
||||||
|
if (before.id == '4') continue;
|
||||||
|
final after = draftsAfter.firstWhere((d) => d.id == before.id);
|
||||||
|
expect(after.category, before.category);
|
||||||
|
expect(after.name, before.name);
|
||||||
|
expect(after.price, before.price);
|
||||||
|
expect(after.status, before.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws StateError for unknown id', () async {
|
||||||
|
expect(
|
||||||
|
() => repository.updateProductCategory('unknown', 'New Cat'),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ void main() {
|
||||||
UpdateProductPrice(repository),
|
UpdateProductPrice(repository),
|
||||||
UpdateProductName(repository),
|
UpdateProductName(repository),
|
||||||
UpdateProductDescription(repository),
|
UpdateProductDescription(repository),
|
||||||
|
UpdateProductCategory(repository),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -492,6 +493,70 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('updateCategory', () {
|
||||||
|
test('updates category and reloads', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 4 starts with category 'Kitchen Accessories'.
|
||||||
|
await controller.updateCategory('4', 'Grippers');
|
||||||
|
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(updated.category, 'Grippers');
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastCategoryResult on success', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateCategory('4', 'Grippers');
|
||||||
|
|
||||||
|
expect(controller.lastCategoryResult, isNotNull);
|
||||||
|
expect(controller.lastCategoryResult!.success, isTrue);
|
||||||
|
expect(controller.lastCategoryResult!.productName, 'Fabric Jar Gripper');
|
||||||
|
expect(controller.lastCategoryResult!.newCategory, 'Grippers');
|
||||||
|
expect(controller.lastCategoryResult!.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastCategoryResult on failure', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateCategory('unknown', 'New Cat');
|
||||||
|
|
||||||
|
expect(controller.lastCategoryResult, isNotNull);
|
||||||
|
expect(controller.lastCategoryResult!.success, isFalse);
|
||||||
|
expect(controller.lastCategoryResult!.errorMessage, isNotNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consumeCategoryResult clears the result', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateCategory('4', 'Grippers');
|
||||||
|
expect(controller.lastCategoryResult, isNotNull);
|
||||||
|
|
||||||
|
controller.consumeCategoryResult();
|
||||||
|
expect(controller.lastCategoryResult, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevents duplicate calls while row is already updating', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final first = controller.updateCategory('4', 'First Category');
|
||||||
|
expect(controller.isUpdating('4'), isTrue);
|
||||||
|
|
||||||
|
final second = controller.updateCategory('4', 'Second Category');
|
||||||
|
await first;
|
||||||
|
await second;
|
||||||
|
|
||||||
|
// Only the first category should have been applied.
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(updated.category, 'First Category');
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Search refinements ──────────────────────────────────────────────
|
// ── Search refinements ──────────────────────────────────────────────
|
||||||
|
|
||||||
group('search: category matching', () {
|
group('search: category matching', () {
|
||||||
|
|
@ -764,6 +829,621 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Post-write consistency ───────────────────────────────────────────
|
||||||
|
|
||||||
|
group('post-write consistency', () {
|
||||||
|
test('selection preserved after status update', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select product 4 (draft).
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
|
||||||
|
// Publish it.
|
||||||
|
await controller.updateStatus('4', PublishStatus.published);
|
||||||
|
|
||||||
|
// Selection should still point to the same product.
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
// And reflect the updated status.
|
||||||
|
expect(controller.selectedDraft!.status, PublishStatus.published);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected draft reflects latest price after update', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.price, 8.50);
|
||||||
|
|
||||||
|
await controller.updatePrice('4', 12.00);
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
expect(controller.selectedDraft!.price, 12.00);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected draft reflects latest name after update', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.name, 'Fabric Jar Gripper');
|
||||||
|
|
||||||
|
await controller.updateName('4', 'Premium Jar Gripper');
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
expect(controller.selectedDraft!.name, 'Premium Jar Gripper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected draft reflects latest description after update', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
final originalDesc = controller.selectedDraft!.description;
|
||||||
|
|
||||||
|
await controller.updateDescription('4', 'Completely new description.');
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
expect(controller.selectedDraft!.description, 'Completely new description.');
|
||||||
|
expect(controller.selectedDraft!.description, isNot(originalDesc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected draft reflects latest category after update', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.category, 'Kitchen Accessories');
|
||||||
|
|
||||||
|
await controller.updateCategory('4', 'Grippers');
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
expect(controller.selectedDraft!.category, 'Grippers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lastModified updates after write', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
final originalModified = controller.selectedDraft!.lastModified;
|
||||||
|
|
||||||
|
await controller.updatePrice('4', 15.00);
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.lastModified.isAfter(originalModified), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection preserved with active filter after write within filter', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Filter to drafts only.
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
// Select product 4 (a draft).
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
|
||||||
|
// Update its price — it stays a draft, so it stays in the filter.
|
||||||
|
await controller.updatePrice('4', 20.00);
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
expect(controller.selectedDraft!.price, 20.00);
|
||||||
|
expect(controller.activeFilter, 'draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection moves when write causes item to leave active filter', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Filter to drafts only.
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
// Select product 4 (a draft).
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
|
||||||
|
// Publish it — it leaves the 'draft' filter.
|
||||||
|
await controller.updateStatus('4', PublishStatus.published);
|
||||||
|
|
||||||
|
// The original selection left the filter; load() auto-selects the
|
||||||
|
// first remaining visible draft.
|
||||||
|
expect(controller.activeFilter, 'draft');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, isNot('4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search persists after write', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('fabric');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.id, '4');
|
||||||
|
|
||||||
|
controller.selectDraft(controller.drafts.first);
|
||||||
|
|
||||||
|
await controller.updatePrice('4', 25.00);
|
||||||
|
|
||||||
|
// Search should still be active.
|
||||||
|
expect(controller.searchQuery, 'fabric');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.price, 25.00);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort persists after write', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.lastModified, ascending: false);
|
||||||
|
|
||||||
|
// Select the first item (most recently modified).
|
||||||
|
controller.selectDraft(controller.drafts.first);
|
||||||
|
|
||||||
|
// Update a different product's price — this changes its lastModified.
|
||||||
|
await controller.updatePrice('1', 99.00);
|
||||||
|
|
||||||
|
// Sort should still be lastModified descending.
|
||||||
|
expect(controller.activeSortField, ProductSortField.lastModified);
|
||||||
|
expect(controller.sortAscending, false);
|
||||||
|
|
||||||
|
// Product 1 should now be first (most recently modified).
|
||||||
|
expect(controller.drafts.first.id, '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('item repositions under active sort after name change', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Default sort: name ascending.
|
||||||
|
// Order: Citrus, Fabric, Floral, Ocean, Skillet, Sublimated
|
||||||
|
expect(controller.drafts.first.name, 'Citrus Coaster Set');
|
||||||
|
|
||||||
|
// Select Fabric Jar Gripper (id 4, currently 2nd).
|
||||||
|
controller.selectBySku('JG-BLU-004');
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
|
||||||
|
// Rename to 'Zzz Gripper' — should move to end of list.
|
||||||
|
await controller.updateName('4', 'Zzz Gripper');
|
||||||
|
|
||||||
|
// Selection should still be on the same product.
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
expect(controller.selectedDraft!.name, 'Zzz Gripper');
|
||||||
|
|
||||||
|
// It should now be last in the sorted list.
|
||||||
|
expect(controller.drafts.last.id, '4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multi-select groundwork ─────────────────────────────────────────
|
||||||
|
|
||||||
|
group('multiSelect', () {
|
||||||
|
test('starts with empty multi-selection', () {
|
||||||
|
expect(controller.multiSelectedIds, isEmpty);
|
||||||
|
expect(controller.multiSelectedCount, 0);
|
||||||
|
expect(controller.isMultiSelectActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleMultiSelect adds an id', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
|
||||||
|
expect(controller.isMultiSelected('1'), true);
|
||||||
|
expect(controller.multiSelectedCount, 1);
|
||||||
|
expect(controller.isMultiSelectActive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleMultiSelect removes an already-selected id', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
expect(controller.isMultiSelected('1'), true);
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
expect(controller.isMultiSelected('1'), false);
|
||||||
|
expect(controller.multiSelectedCount, 0);
|
||||||
|
expect(controller.isMultiSelectActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple ids can be multi-selected', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
controller.toggleMultiSelect('3');
|
||||||
|
controller.toggleMultiSelect('5');
|
||||||
|
|
||||||
|
expect(controller.multiSelectedCount, 3);
|
||||||
|
expect(controller.isMultiSelected('1'), true);
|
||||||
|
expect(controller.isMultiSelected('3'), true);
|
||||||
|
expect(controller.isMultiSelected('5'), true);
|
||||||
|
expect(controller.isMultiSelected('2'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearMultiSelection removes all multi-selected ids', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
controller.toggleMultiSelect('2');
|
||||||
|
controller.toggleMultiSelect('3');
|
||||||
|
expect(controller.multiSelectedCount, 3);
|
||||||
|
|
||||||
|
controller.clearMultiSelection();
|
||||||
|
|
||||||
|
expect(controller.multiSelectedIds, isEmpty);
|
||||||
|
expect(controller.multiSelectedCount, 0);
|
||||||
|
expect(controller.isMultiSelectActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAllVisible selects all visible draft ids', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectAllVisible();
|
||||||
|
|
||||||
|
expect(controller.multiSelectedCount, 6);
|
||||||
|
for (final draft in controller.drafts) {
|
||||||
|
expect(controller.isMultiSelected(draft.id), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAllVisible respects active filter', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
controller.selectAllVisible();
|
||||||
|
|
||||||
|
expect(controller.multiSelectedCount, 2);
|
||||||
|
expect(controller.isMultiSelected('4'), true);
|
||||||
|
expect(controller.isMultiSelected('5'), true);
|
||||||
|
// Non-visible ids should not be selected.
|
||||||
|
expect(controller.isMultiSelected('1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-select is independent of single-item preview selection', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Multi-select two items.
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
controller.toggleMultiSelect('3');
|
||||||
|
|
||||||
|
// Single-select a different item for preview.
|
||||||
|
controller.selectDraft(controller.drafts.firstWhere((d) => d.id == '5'));
|
||||||
|
|
||||||
|
// Preview selection should be independent.
|
||||||
|
expect(controller.selectedDraft!.id, '5');
|
||||||
|
expect(controller.isMultiSelected('5'), false);
|
||||||
|
expect(controller.multiSelectedCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-selection persists across load cycles', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('2');
|
||||||
|
controller.toggleMultiSelect('4');
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.isMultiSelected('2'), true);
|
||||||
|
expect(controller.isMultiSelected('4'), true);
|
||||||
|
expect(controller.multiSelectedCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-selection persists after write operations', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
controller.toggleMultiSelect('4');
|
||||||
|
|
||||||
|
// Update price on product 4.
|
||||||
|
await controller.updatePrice('4', 20.00);
|
||||||
|
|
||||||
|
// Multi-selection should still be intact.
|
||||||
|
expect(controller.isMultiSelected('1'), true);
|
||||||
|
expect(controller.isMultiSelected('4'), true);
|
||||||
|
expect(controller.multiSelectedCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleMultiSelect notifies listeners', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
int notifyCount = 0;
|
||||||
|
controller.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
expect(notifyCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearMultiSelection notifies listeners', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
|
||||||
|
int notifyCount = 0;
|
||||||
|
controller.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
controller.clearMultiSelection();
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAllVisible notifies listeners', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
int notifyCount = 0;
|
||||||
|
controller.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
controller.selectAllVisible();
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAllVisible with search selects only matching products', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('coaster');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
controller.selectAllVisible();
|
||||||
|
|
||||||
|
expect(controller.multiSelectedCount, 2);
|
||||||
|
expect(controller.isMultiSelected('2'), true); // Citrus Coaster Set
|
||||||
|
expect(controller.isMultiSelected('6'), true); // Sublimated Slate Coaster
|
||||||
|
expect(controller.isMultiSelected('1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAllVisible is additive to existing selection', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select one product manually.
|
||||||
|
controller.toggleMultiSelect('1');
|
||||||
|
|
||||||
|
// Filter to drafts only and select all visible.
|
||||||
|
controller.setFilter('draft');
|
||||||
|
controller.selectAllVisible();
|
||||||
|
|
||||||
|
// Should have the manually selected + the two drafts.
|
||||||
|
expect(controller.isMultiSelected('1'), true);
|
||||||
|
expect(controller.isMultiSelected('4'), true);
|
||||||
|
expect(controller.isMultiSelected('5'), true);
|
||||||
|
expect(controller.multiSelectedCount, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── List density ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('listDensity', () {
|
||||||
|
test('defaults to standard', () {
|
||||||
|
expect(controller.listDensity, ListDensity.standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleListDensity switches to compact', () {
|
||||||
|
controller.toggleListDensity();
|
||||||
|
expect(controller.listDensity, ListDensity.compact);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleListDensity switches back to standard', () {
|
||||||
|
controller.toggleListDensity();
|
||||||
|
expect(controller.listDensity, ListDensity.compact);
|
||||||
|
|
||||||
|
controller.toggleListDensity();
|
||||||
|
expect(controller.listDensity, ListDensity.standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleListDensity notifies listeners', () {
|
||||||
|
int notifyCount = 0;
|
||||||
|
controller.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
controller.toggleListDensity();
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listDensity persists across load cycles', () async {
|
||||||
|
controller.toggleListDensity();
|
||||||
|
expect(controller.listDensity, ListDensity.compact);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.listDensity, ListDensity.compact);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Staleness detection ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('staleness', () {
|
||||||
|
test('product modified today is not stale', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final fresh = controller.drafts.first;
|
||||||
|
// Use a reference time very close to the fake data dates.
|
||||||
|
final now = DateTime(2026, 4, 3);
|
||||||
|
expect(controller.isStale(fresh, now: now), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product modified 30+ days ago is stale', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 6 has lastModified = 2026-03-20.
|
||||||
|
final old = controller.drafts.firstWhere((d) => d.id == '6');
|
||||||
|
final now = DateTime(2026, 4, 20); // 31 days later
|
||||||
|
expect(controller.isStale(old, now: now), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product modified exactly 30 days ago is stale', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 6 has lastModified = 2026-03-20.
|
||||||
|
final old = controller.drafts.firstWhere((d) => d.id == '6');
|
||||||
|
final now = DateTime(2026, 4, 19); // exactly 30 days
|
||||||
|
expect(controller.isStale(old, now: now), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product modified 29 days ago is not stale', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 6 has lastModified = 2026-03-20.
|
||||||
|
final old = controller.drafts.firstWhere((d) => d.id == '6');
|
||||||
|
final now = DateTime(2026, 4, 18); // 29 days
|
||||||
|
expect(controller.isStale(old, now: now), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('staleCount returns correct count', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// With now = 2026-05-01, all 6 products have lastModified in March/April.
|
||||||
|
// All are older than 30 days from May 1.
|
||||||
|
// Dates: Mar20, Mar25, Mar28, Apr1, Apr2, Apr3
|
||||||
|
// Days from May 1: 42, 37, 34, 30, 29, 28
|
||||||
|
// Stale (>=30): Mar20(42), Mar25(37), Mar28(34), Apr1(30) = 4
|
||||||
|
final now = DateTime(2026, 5, 1);
|
||||||
|
expect(controller.staleCount(now: now), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('staleCount respects active filter', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Filter to drafts only (ids 4 and 5, dates Apr2 and Apr3).
|
||||||
|
controller.setFilter('draft');
|
||||||
|
|
||||||
|
// With now = 2026-05-03: Apr2 = 31 days (stale), Apr3 = 30 days (stale)
|
||||||
|
final now = DateTime(2026, 5, 3);
|
||||||
|
expect(controller.staleCount(now: now), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('freshly written product is no longer stale', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 6 (Mar20) is stale as of May 1.
|
||||||
|
final now = DateTime(2026, 5, 1);
|
||||||
|
final oldProduct = controller.drafts.firstWhere((d) => d.id == '6');
|
||||||
|
expect(controller.isStale(oldProduct, now: now), isTrue);
|
||||||
|
|
||||||
|
// Update its price — the fake repo sets lastModified to DateTime.now().
|
||||||
|
await controller.updatePrice('6', 20.00);
|
||||||
|
|
||||||
|
// After the write, the product has a fresh lastModified.
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '6');
|
||||||
|
expect(controller.isStale(updated), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Keyboard navigation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('keyboard navigation', () {
|
||||||
|
test('selectNextDraft moves to next item', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Default sort: name ascending. First selected = Citrus Coaster Set (id 2).
|
||||||
|
expect(controller.selectedDraft!.id, '2');
|
||||||
|
|
||||||
|
controller.selectNextDraft();
|
||||||
|
// Next: Fabric Jar Gripper (id 4).
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectNextDraft wraps to first at end of list', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select last item: Sublimated Slate Coaster (id 6).
|
||||||
|
controller.selectDraft(controller.drafts.last);
|
||||||
|
expect(controller.selectedDraft!.id, '6');
|
||||||
|
|
||||||
|
controller.selectNextDraft();
|
||||||
|
// Should wrap to first: Citrus Coaster Set (id 2).
|
||||||
|
expect(controller.selectedDraft!.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectPreviousDraft moves to previous item', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Select 2nd item: Fabric Jar Gripper (id 4).
|
||||||
|
controller.selectDraft(controller.drafts[1]);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
|
||||||
|
controller.selectPreviousDraft();
|
||||||
|
// Previous: Citrus Coaster Set (id 2).
|
||||||
|
expect(controller.selectedDraft!.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectPreviousDraft wraps to last at beginning of list', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Already at first item: Citrus Coaster Set (id 2).
|
||||||
|
expect(controller.selectedDraft!.id, '2');
|
||||||
|
|
||||||
|
controller.selectPreviousDraft();
|
||||||
|
// Should wrap to last: Sublimated Slate Coaster (id 6).
|
||||||
|
expect(controller.selectedDraft!.id, '6');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectNextDraft selects first when no selection', () async {
|
||||||
|
await controller.load();
|
||||||
|
controller.selectedDraft = null;
|
||||||
|
|
||||||
|
final result = controller.selectNextDraft();
|
||||||
|
expect(result, isTrue);
|
||||||
|
expect(controller.selectedDraft!.id, '2'); // First in list.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectPreviousDraft selects last when no selection', () async {
|
||||||
|
await controller.load();
|
||||||
|
controller.selectedDraft = null;
|
||||||
|
|
||||||
|
final result = controller.selectPreviousDraft();
|
||||||
|
expect(result, isTrue);
|
||||||
|
expect(controller.selectedDraft!.id, '6'); // Last in list.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectNextDraft returns false on empty list', () async {
|
||||||
|
await controller.load();
|
||||||
|
controller.setSearchQuery('zzz_no_match');
|
||||||
|
|
||||||
|
final result = controller.selectNextDraft();
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectPreviousDraft returns false on empty list', () async {
|
||||||
|
await controller.load();
|
||||||
|
controller.setSearchQuery('zzz_no_match');
|
||||||
|
|
||||||
|
final result = controller.selectPreviousDraft();
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectNextDraft notifies listeners', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
int notifyCount = 0;
|
||||||
|
controller.addListener(() => notifyCount++);
|
||||||
|
|
||||||
|
controller.selectNextDraft();
|
||||||
|
expect(notifyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard navigation respects active filter', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Filter to drafts only (ids 4 and 5, name-sorted: Fabric, Skillet).
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
// Auto-selection cleared or set to first visible.
|
||||||
|
controller.selectDraft(controller.drafts.first);
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
|
||||||
|
controller.selectNextDraft();
|
||||||
|
expect(controller.selectedDraft!.id, '5');
|
||||||
|
|
||||||
|
controller.selectNextDraft();
|
||||||
|
// Wraps back to first.
|
||||||
|
expect(controller.selectedDraft!.id, '4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('disposed guard', () {
|
group('disposed guard', () {
|
||||||
test('load does not notify after disposal', () async {
|
test('load does not notify after disposal', () async {
|
||||||
// Start load, then immediately dispose.
|
// Start load, then immediately dispose.
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,25 @@ void main() {
|
||||||
lastModified: DateTime(2026, 4, 1),
|
lastModified: DateTime(2026, 4, 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget buildTestWidget({ProductDraft? draft, bool isSelected = false, VoidCallback? onTap}) {
|
Widget buildTestWidget({
|
||||||
|
ProductDraft? draft,
|
||||||
|
bool isSelected = false,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
bool isCompact = false,
|
||||||
|
bool isStale = false,
|
||||||
|
}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
theme: buildKcTheme(),
|
theme: buildKcTheme(),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: SizedBox(
|
body: SizedBox(
|
||||||
height: 200,
|
height: isCompact ? 72 : 200,
|
||||||
width: 400,
|
width: isCompact ? 600 : 400,
|
||||||
child: ProductDraftCard(
|
child: ProductDraftCard(
|
||||||
draft: draft ?? sampleDraft,
|
draft: draft ?? sampleDraft,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
isCompact: isCompact,
|
||||||
|
isStale: isStale,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -78,5 +86,62 @@ void main() {
|
||||||
await tester.tap(find.text('Test Bowl Cozy'));
|
await tester.tap(find.text('Test Bowl Cozy'));
|
||||||
expect(tapped, true);
|
expect(tapped, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Description snippet (standard mode) ────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('standard mode shows description snippet', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget());
|
||||||
|
expect(find.text('A test product'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stale indicator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('shows stale indicator when isStale is true', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isStale: true));
|
||||||
|
expect(find.byIcon(Icons.schedule), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides stale indicator when isStale is false', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isStale: false));
|
||||||
|
expect(find.byIcon(Icons.schedule), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Compact mode ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('compact mode displays product name', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true));
|
||||||
|
expect(find.text('Test Bowl Cozy'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact mode displays SKU without prefix', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true));
|
||||||
|
// In compact mode, the SKU is shown without the "SKU: " prefix.
|
||||||
|
expect(find.text('BC-TST-001'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact mode displays price', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true));
|
||||||
|
expect(find.text('\$12.99'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact mode displays status chip', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true));
|
||||||
|
expect(find.text('Draft'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact mode displays last modified date', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true));
|
||||||
|
expect(find.text('2026-04-01'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact mode shows stale indicator when isStale is true', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true, isStale: true));
|
||||||
|
expect(find.byIcon(Icons.schedule), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact mode hides stale indicator when isStale is false', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(isCompact: true, isStale: false));
|
||||||
|
expect(find.byIcon(Icons.schedule), findsNothing);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ void main() {
|
||||||
ValueChanged<double>? onPriceChanged,
|
ValueChanged<double>? onPriceChanged,
|
||||||
ValueChanged<String>? onNameChanged,
|
ValueChanged<String>? onNameChanged,
|
||||||
ValueChanged<String>? onDescriptionChanged,
|
ValueChanged<String>? onDescriptionChanged,
|
||||||
|
ValueChanged<String>? onCategoryChanged,
|
||||||
bool isUpdating = false,
|
bool isUpdating = false,
|
||||||
}) {
|
}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
|
@ -86,6 +87,7 @@ void main() {
|
||||||
onPriceChanged: onPriceChanged,
|
onPriceChanged: onPriceChanged,
|
||||||
onNameChanged: onNameChanged,
|
onNameChanged: onNameChanged,
|
||||||
onDescriptionChanged: onDescriptionChanged,
|
onDescriptionChanged: onDescriptionChanged,
|
||||||
|
onCategoryChanged: onCategoryChanged,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -560,4 +562,221 @@ void main() {
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Inline validation error messages (Stage 2B) ─────────────────────
|
||||||
|
|
||||||
|
group('inline validation errors', () {
|
||||||
|
testWidgets('shows error when submitting empty name', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Name cannot be empty.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('clears name error on cancel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Trigger the error.
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save name'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Name cannot be empty.'), findsOneWidget);
|
||||||
|
|
||||||
|
// Cancel should clear the error and exit edit mode.
|
||||||
|
await tester.tap(find.byTooltip('Cancel'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Name cannot be empty.'), findsNothing);
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error when submitting invalid price', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Clear the field (empty string is not a valid price).
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byIcon(Icons.check));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Enter a valid price.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('clears price error on cancel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byIcon(Icons.check));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Enter a valid price.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Enter a valid price.'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error when submitting empty description', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit description'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save description'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Description cannot be empty.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('clears description error on cancel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit description'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save description'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Description cannot be empty.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Cancel description edit'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Description cannot be empty.'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error when submitting empty category', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit category'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save category'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Category cannot be empty.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('clears category error on cancel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit category'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save category'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Category cannot be empty.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Cancel'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Category cannot be empty.'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('valid input clears previous error', (tester) async {
|
||||||
|
String? receivedName;
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (n) => receivedName = n));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Trigger error first.
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save name'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Name cannot be empty.'), findsOneWidget);
|
||||||
|
|
||||||
|
// Now enter valid input and submit.
|
||||||
|
await tester.enterText(find.byType(TextField), 'Valid Name');
|
||||||
|
await tester.tap(find.byTooltip('Save name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Name cannot be empty.'), findsNothing);
|
||||||
|
expect(receivedName, 'Valid Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Disabled state during isUpdating (Stage 2B) ─────────────────────
|
||||||
|
|
||||||
|
group('edit fields disabled during isUpdating', () {
|
||||||
|
testWidgets('name text field disabled while updating', (tester) async {
|
||||||
|
// Start with isUpdating = false to enter edit mode.
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
|
||||||
|
// Rebuild with isUpdating = true.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onNameChanged: (_) {}, isUpdating: true),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// The text field should be disabled.
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('price text field disabled while updating', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Rebuild with isUpdating = true.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onPriceChanged: (_) {}, isUpdating: true),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('description text field disabled while updating', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit description'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Rebuild with isUpdating = true.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}, isUpdating: true),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('category text field disabled while updating', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit category'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Rebuild with isUpdating = true.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onCategoryChanged: (_) {}, isUpdating: true),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.enabled, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ class _FailingRepository implements ProductPublishingRepository {
|
||||||
@override
|
@override
|
||||||
Future<ProductDraft> updateProductDescription(String id, String description) async =>
|
Future<ProductDraft> updateProductDescription(String id, String description) async =>
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductCategory(String id, String category) async =>
|
||||||
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ void main() {
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Failed to publish Floral Bowl Cozy.'), findsOneWidget);
|
expect(find.text('Failed to publish Floral Bowl Cozy. Network error'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ void main() {
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Failed to move to draft Ocean Nightlight.'), findsOneWidget);
|
expect(find.text('Failed to move to draft Ocean Nightlight. Server error'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
CI/CD helper scripts for the Kell Creations Flutter monorepo.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
### `run_all_tests.sh`
|
||||||
|
|
||||||
|
Runs `flutter test` across all testable packages and apps, producing a per-package pass/fail summary.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From kell_creations_apps/ directory
|
||||||
|
./tools/run_all_tests.sh # Run tests only
|
||||||
|
./tools/run_all_tests.sh --analyze # Run dart analyze + tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
|
||||||
|
1. Installs dependencies (`flutter pub get`) for all packages and apps.
|
||||||
|
2. Optionally runs `dart analyze --fatal-infos` on each package/app.
|
||||||
|
3. Runs `flutter test --reporter expanded` for packages with tests.
|
||||||
|
4. Prints an aggregate pass/fail summary table.
|
||||||
|
|
||||||
|
**Adding new packages:**
|
||||||
|
|
||||||
|
When a new package gains tests, add its path to the `TESTABLE` array in the script. For packages that should be analyzed but have no tests yet, add to the `ANALYZABLE` array only.
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
|
||||||
|
- `0` — all tests passed (and analyze clean, if `--analyze` was used)
|
||||||
|
- `1` — one or more failures detected
|
||||||
|
|
||||||
|
### `collect_coverage.sh`
|
||||||
|
|
||||||
|
Runs `flutter test --coverage` across all testable packages and apps, parses the generated `lcov.info` files, and produces a combined summary table with test counts and line coverage percentages.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From kell_creations_apps/ directory
|
||||||
|
./tools/collect_coverage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
|
||||||
|
1. Installs dependencies (`flutter pub get`) for testable packages.
|
||||||
|
2. Runs `flutter test --coverage --reporter expanded` for each package.
|
||||||
|
3. Parses `coverage/lcov.info` to extract lines hit / lines found per package.
|
||||||
|
4. Prints an aggregate summary table with pass/fail counts and coverage percentages.
|
||||||
|
|
||||||
|
**Output example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══════════════════════════════════════════════════════════╗
|
||||||
|
║ Flutter Test & Coverage Summary ║
|
||||||
|
╠═══════════════════════════════════════════════════════════╣
|
||||||
|
║ Package Pass Fail Lines Coverage ║
|
||||||
|
╠═══════════════════════════════════════════════════════════╣
|
||||||
|
║ core 20 0 120/150 80.0% ║
|
||||||
|
║ design_system 41 0 95/110 86.4% ║
|
||||||
|
║ ... ║
|
||||||
|
╠═══════════════════════════════════════════════════════════╣
|
||||||
|
║ TOTAL 379 0 500/600 83.3% ║
|
||||||
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
|
||||||
|
- `0` — all tests passed
|
||||||
|
- `1` — one or more failures detected
|
||||||
|
|
||||||
|
## CI Workflows
|
||||||
|
|
||||||
|
The corresponding Forgejo Actions workflows live in `.forgejo/workflows/`:
|
||||||
|
|
||||||
|
| Workflow | Trigger | Purpose |
|
||||||
|
| --------------------- | ----------------- | -------------------------------------------- |
|
||||||
|
| `flutter-analyze.yml` | PRs and branches | Runs `dart analyze` on all packages and apps |
|
||||||
|
| `flutter-test.yml` | PRs and branches | Runs `flutter test` with result reporting |
|
||||||
|
| `validate-docs.yml` | Non-main branches | Validates MkDocs documentation build |
|
||||||
|
| `publish-docs.yml` | Push to main | Publishes documentation to docs host |
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# collect_coverage.sh — Run flutter test --coverage and report results
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./tools/collect_coverage.sh # Run from kell_creations_apps/
|
||||||
|
#
|
||||||
|
# Generates coverage/lcov.info per package, then prints a summary table
|
||||||
|
# showing test pass/fail counts and line coverage percentage.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 — all tests passed
|
||||||
|
# 1 — one or more failures
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Packages/apps with tests (add new ones here as they gain tests)
|
||||||
|
TESTABLE=(
|
||||||
|
packages/core
|
||||||
|
packages/design_system
|
||||||
|
packages/feature_wordpress
|
||||||
|
apps/kell_web
|
||||||
|
)
|
||||||
|
|
||||||
|
OVERALL_EXIT=0
|
||||||
|
|
||||||
|
# ── Dependency install ───────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Installing dependencies"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
|
||||||
|
for pkg in "${TESTABLE[@]}"; do
|
||||||
|
echo " → $pkg"
|
||||||
|
(cd "$ROOT_DIR/$pkg" && flutter pub get --no-example) > /dev/null 2>&1
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Tests with coverage ─────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Running flutter test --coverage"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
|
||||||
|
declare -A RESULTS_PASS
|
||||||
|
declare -A RESULTS_FAIL
|
||||||
|
declare -A RESULTS_LH
|
||||||
|
declare -A RESULTS_LF
|
||||||
|
declare -A RESULTS_PCT
|
||||||
|
|
||||||
|
for pkg in "${TESTABLE[@]}"; do
|
||||||
|
NAME=$(basename "$pkg")
|
||||||
|
echo ""
|
||||||
|
echo " ── $NAME ──"
|
||||||
|
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
if (cd "$ROOT_DIR/$pkg" && flutter test --coverage --reporter expanded 2>&1) | tee "$TMPFILE"; then
|
||||||
|
: # tests passed
|
||||||
|
else
|
||||||
|
OVERALL_EXIT=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PASS=$(grep -cE '^\s*✓' "$TMPFILE" 2>/dev/null || echo "0")
|
||||||
|
FAIL=$(grep -cE '^\s*✗' "$TMPFILE" 2>/dev/null || echo "0")
|
||||||
|
RESULTS_PASS[$NAME]=$PASS
|
||||||
|
RESULTS_FAIL[$NAME]=$FAIL
|
||||||
|
rm -f "$TMPFILE"
|
||||||
|
|
||||||
|
# Parse lcov.info for coverage
|
||||||
|
LCOV="$ROOT_DIR/$pkg/coverage/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="0.0"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
LF=0
|
||||||
|
LH=0
|
||||||
|
PCT="—"
|
||||||
|
fi
|
||||||
|
RESULTS_LH[$NAME]=$LH
|
||||||
|
RESULTS_LF[$NAME]=$LF
|
||||||
|
RESULTS_PCT[$NAME]=$PCT
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Summary ──────────────────────────────────────────────────────────
|
||||||
|
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 "${TESTABLE[@]}"; do
|
||||||
|
NAME=$(basename "$pkg")
|
||||||
|
P=${RESULTS_PASS[$NAME]:-0}
|
||||||
|
F=${RESULTS_FAIL[$NAME]:-0}
|
||||||
|
LH=${RESULTS_LH[$NAME]:-0}
|
||||||
|
LF=${RESULTS_LF[$NAME]:-0}
|
||||||
|
PCT=${RESULTS_PCT[$NAME]:-"—"}
|
||||||
|
|
||||||
|
if [ "$PCT" != "—" ]; then
|
||||||
|
LINES="$LH/$LF"
|
||||||
|
PCT_DISPLAY="${PCT}%"
|
||||||
|
else
|
||||||
|
LINES="—"
|
||||||
|
PCT_DISPLAY="—"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "║ %-20s %-7s %-7s %-8s %-10s ║\n" "$NAME" "$P" "$F" "$LINES" "$PCT_DISPLAY"
|
||||||
|
TOTAL_PASS=$((TOTAL_PASS + P))
|
||||||
|
TOTAL_FAIL=$((TOTAL_FAIL + F))
|
||||||
|
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"
|
||||||
|
TOTAL_PCT_DISPLAY="${TOTAL_PCT}%"
|
||||||
|
else
|
||||||
|
TOTAL_LINES="—"
|
||||||
|
TOTAL_PCT_DISPLAY="—"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "╠═══════════════════════════════════════════════════════════╣"
|
||||||
|
printf "║ %-20s %-7s %-7s %-8s %-10s ║\n" "TOTAL" "$TOTAL_PASS" "$TOTAL_FAIL" "$TOTAL_LINES" "$TOTAL_PCT_DISPLAY"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════╝"
|
||||||
|
|
||||||
|
if [ $OVERALL_EXIT -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Failures detected. See details above."
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "✅ All $TOTAL_PASS tests passed across all packages."
|
||||||
|
echo "📊 Overall line coverage: $TOTAL_PCT_DISPLAY ($TOTAL_LINES lines)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $OVERALL_EXIT
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# run_all_tests.sh — Run flutter test for all testable packages and apps
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./tools/run_all_tests.sh # Run from kell_creations_apps/
|
||||||
|
# ./tools/run_all_tests.sh --analyze # Also run dart analyze first
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 — all tests passed (and analyze clean, if requested)
|
||||||
|
# 1 — one or more failures
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Packages/apps with tests (add new ones here as they gain tests)
|
||||||
|
TESTABLE=(
|
||||||
|
packages/core
|
||||||
|
packages/design_system
|
||||||
|
packages/feature_wordpress
|
||||||
|
apps/kell_web
|
||||||
|
)
|
||||||
|
|
||||||
|
# All packages/apps to analyze (includes those without tests)
|
||||||
|
ANALYZABLE=(
|
||||||
|
packages/core
|
||||||
|
packages/design_system
|
||||||
|
packages/feature_wordpress
|
||||||
|
packages/feature_inventory
|
||||||
|
packages/feature_orders
|
||||||
|
packages/feature_policy
|
||||||
|
apps/kell_web
|
||||||
|
apps/kell_mobile
|
||||||
|
)
|
||||||
|
|
||||||
|
RUN_ANALYZE=false
|
||||||
|
if [[ "${1:-}" == "--analyze" ]]; then
|
||||||
|
RUN_ANALYZE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
OVERALL_EXIT=0
|
||||||
|
|
||||||
|
# ── Dependency install ───────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Installing dependencies"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
|
||||||
|
for pkg in "${ANALYZABLE[@]}"; do
|
||||||
|
echo " → $pkg"
|
||||||
|
(cd "$ROOT_DIR/$pkg" && flutter pub get --no-example) > /dev/null 2>&1
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Analyze (optional) ──────────────────────────────────────────────
|
||||||
|
if $RUN_ANALYZE; then
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Running dart analyze"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
|
||||||
|
ANALYZE_FAILURES=()
|
||||||
|
for pkg in "${ANALYZABLE[@]}"; do
|
||||||
|
NAME=$(basename "$pkg")
|
||||||
|
printf " %-25s" "$NAME"
|
||||||
|
if (cd "$ROOT_DIR/$pkg" && dart analyze --fatal-infos) > /dev/null 2>&1; then
|
||||||
|
echo "✅ clean"
|
||||||
|
else
|
||||||
|
echo "❌ issues found"
|
||||||
|
ANALYZE_FAILURES+=("$NAME")
|
||||||
|
OVERALL_EXIT=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#ANALYZE_FAILURES[@]} -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo " ❌ Analyze failures: ${ANALYZE_FAILURES[*]}"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo " ✅ All packages analyze clean"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Running flutter test"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
|
||||||
|
declare -A RESULTS_PASS
|
||||||
|
declare -A RESULTS_FAIL
|
||||||
|
TEST_FAILURES=()
|
||||||
|
|
||||||
|
for pkg in "${TESTABLE[@]}"; do
|
||||||
|
NAME=$(basename "$pkg")
|
||||||
|
echo ""
|
||||||
|
echo " ── $NAME ──"
|
||||||
|
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
if (cd "$ROOT_DIR/$pkg" && flutter test --reporter expanded 2>&1) | tee "$TMPFILE"; then
|
||||||
|
: # tests passed
|
||||||
|
else
|
||||||
|
TEST_FAILURES+=("$NAME")
|
||||||
|
OVERALL_EXIT=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PASS=$(grep -cE '^\s*✓' "$TMPFILE" 2>/dev/null || echo "0")
|
||||||
|
FAIL=$(grep -cE '^\s*✗' "$TMPFILE" 2>/dev/null || echo "0")
|
||||||
|
RESULTS_PASS[$NAME]=$PASS
|
||||||
|
RESULTS_FAIL[$NAME]=$FAIL
|
||||||
|
rm -f "$TMPFILE"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Summary ──────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════╗"
|
||||||
|
echo "║ Flutter Test Results Summary ║"
|
||||||
|
echo "╠══════════════════════════════════════╣"
|
||||||
|
echo "║ Package Pass Fail ║"
|
||||||
|
echo "╠══════════════════════════════════════╣"
|
||||||
|
|
||||||
|
TOTAL_PASS=0
|
||||||
|
TOTAL_FAIL=0
|
||||||
|
|
||||||
|
for pkg in "${TESTABLE[@]}"; do
|
||||||
|
NAME=$(basename "$pkg")
|
||||||
|
P=${RESULTS_PASS[$NAME]:-0}
|
||||||
|
F=${RESULTS_FAIL[$NAME]:-0}
|
||||||
|
printf "║ %-20s %-7s %-7s ║\n" "$NAME" "$P" "$F"
|
||||||
|
TOTAL_PASS=$((TOTAL_PASS + P))
|
||||||
|
TOTAL_FAIL=$((TOTAL_FAIL + F))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "╠══════════════════════════════════════╣"
|
||||||
|
printf "║ %-20s %-7s %-7s ║\n" "TOTAL" "$TOTAL_PASS" "$TOTAL_FAIL"
|
||||||
|
echo "╚══════════════════════════════════════╝"
|
||||||
|
|
||||||
|
if [ $OVERALL_EXIT -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Failures detected. See details above."
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "✅ All $TOTAL_PASS tests passed across all packages."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $OVERALL_EXIT
|
||||||
|
|
@ -67,6 +67,7 @@ nav:
|
||||||
- Operations:
|
- Operations:
|
||||||
- Operations Overview: operations/index.md
|
- Operations Overview: operations/index.md
|
||||||
- CI/CD Workflow: operations/cicd-workflow.md
|
- CI/CD Workflow: operations/cicd-workflow.md
|
||||||
|
- Architecture Workflow: operations/architecture-workflow.md
|
||||||
- Standards:
|
- Standards:
|
||||||
- Standards Overview: "standards/index.md"
|
- Standards Overview: "standards/index.md"
|
||||||
- Integrations:
|
- Integrations:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue