Compare commits
31 Commits
feat/publi
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
b61c886c23 | |
|
|
2c3ed3b926 | |
|
|
ffc643739c | |
|
|
8aec65b46b | |
|
|
fba7cba835 | |
|
|
871ae8c48b | |
|
|
4b5c96c5ec | |
|
|
e23d41b098 | |
|
|
591de0c5c4 | |
|
|
65466ba513 | |
|
|
f056d5f0b5 | |
|
|
effaadc84b | |
|
|
2af9a8b6cb | |
|
|
4f72bdb486 | |
|
|
f30ad24d8a | |
|
|
71abe9df7f | |
|
|
6a6323ef57 | |
|
|
b00072474b | |
|
|
0a0abc2c3d | |
|
|
9eafc68fec | |
|
|
6f10efc88d | |
|
|
8facefdff1 | |
|
|
bee610ca2c | |
|
|
02090cde6a | |
|
|
eaf3e70d30 | |
|
|
738336d953 | |
|
|
a0aea373c2 | |
|
|
f3fbbca06d | |
|
|
dfe7ae1811 | |
|
|
49a3702cec | |
|
|
b81016df28 |
|
|
@ -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/
|
||||
.DS_Store
|
||||
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.
|
||||
|
||||
## Diagram Source
|
||||
|
||||
The source for this diagram is maintained as architecture code in:
|
||||
|
||||
`architecture/workspace/components-inventory.puml`
|
||||
|
||||
## Diagram
|
||||
|
||||

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

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
## Diagram Source
|
||||
|
||||
The source for this diagram is maintained as architecture code in:
|
||||
|
||||
`architecture/workspace/containers-platform.puml`
|
||||
|
||||
## Diagram
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -1,3 +1,84 @@
|
|||
# ADR Index
|
||||
|
||||
Architecture Decision Records will be maintained here.
|
||||
|
||||
## Current State
|
||||
|
||||
No formal ADRs have been recorded yet. The following decisions have been identified through project analysis as candidates for retroactive ADR documentation.
|
||||
|
||||
!!! info "Last analyzed: 2026-05-22"
|
||||
|
||||
## Recommended ADRs
|
||||
|
||||
The following significant architecture decisions are already in effect across the platform. Recording them as formal ADRs would establish an auditable decision history and provide context for future changes.
|
||||
|
||||
### ADR-001 — Use C4 model with PlantUML as architecture-as-code standard
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** The platform needs a consistent architecture documentation approach that supports version control and CI/CD rendering
|
||||
- **Decision:** Use the C4 model (landscape, context, container, component) with PlantUML sources maintained in `architecture/workspace/`
|
||||
- **Consequences:** All 19 architecture diagrams follow this pattern; diagrams are rendered automatically via CI/CD; local preview requires PlantUML server access
|
||||
|
||||
### ADR-002 — Use Flutter monorepo with shared packages for cross-platform applications
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** The platform needs to support both web and Android applications with shared business logic
|
||||
- **Decision:** Use a Flutter monorepo structure under `kell_creations_apps/` with shared packages (`core`, `design_system`, `feature_*`) and platform-specific app targets (`kell_web`, `kell_mobile`)
|
||||
- **Consequences:** Business logic is shared; domain/application/data/presentation layers are separated per package; each app target composes its own shell and routing
|
||||
|
||||
### ADR-003 — Use `--dart-define` for runtime environment selection
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** The platform needs to switch between fake and real backends without code changes
|
||||
- **Decision:** Use `--dart-define` keys (`KC_ENV`, `KC_WC_SITE_URL`, etc.) to select runtime environment at build time
|
||||
- **Consequences:** No settings UI needed; credentials are never hardcoded; `KcBootstrap` handles fallback to fake mode when WP credentials are missing
|
||||
|
||||
### ADR-004 — Use MkDocs Material with Forgejo CI/CD for documentation publishing
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** The platform needs a docs-as-code publishing pipeline
|
||||
- **Decision:** Use MkDocs Material as the documentation framework, published via Forgejo Actions with dedicated runners on the Ubuntu documentation host
|
||||
- **Consequences:** Documentation is version-controlled; publish and validate workflows are separated by branch; the published site at `docs.kellsupport.com` is automatically updated on `main` branch pushes
|
||||
|
||||
### ADR-005 — Use abstract service composition pattern in `core` for cross-platform app shells
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** `kell_web` and `kell_mobile` need to share composition logic without circular dependencies
|
||||
- **Decision:** Extract abstract composition types (`KcAppConfig`, `KcAppServices`, `KcServiceFactory`, `KcBootstrap`, `KcAppScope`) into the `core` package; each app target provides concrete implementations
|
||||
- **Consequences:** Composition contract is enforced; circular dependencies avoided; `kell_web` retains backward-compatible typedefs; documented in `kell_creations_apps/docs/composition-strategy.md`
|
||||
|
||||
### ADR-006 — Maintain policy repository with controlled lifecycle and YAML front matter metadata
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** The business needs formal governance for policies, procedures, standards, and exceptions
|
||||
- **Decision:** Use a Git-based policy repository with lifecycle directories (`drafts/`, `review/`, `active/`, `retired/`), YAML front matter metadata, CSV registers, and evidence retention
|
||||
- **Consequences:** All controlled documents follow a defined lifecycle; metadata requirements prevent informal policy creation; registers and evidence support audit readiness
|
||||
|
||||
### ADR-007 — Use Forgejo Actions with dedicated runners for CI/CD pipelines
|
||||
|
||||
- **Status:** Accepted (in practice)
|
||||
- **Context:** The platform needs automated validation and publishing for both documentation and Flutter applications
|
||||
- **Decision:** Use Forgejo Actions with two runner types: a Docker runner for containerized workloads and a host runner for direct filesystem access (publishing, rsync)
|
||||
- **Consequences:** Four workflows operational (`publish-docs`, `validate-docs`, `flutter-analyze`, `flutter-test`); runner tokens must be managed securely; host runner limited to narrowly defined workflows
|
||||
|
||||
## ADR Template
|
||||
|
||||
When recording new ADRs, use the following structure:
|
||||
|
||||
```markdown
|
||||
### ADR-NNN — Title
|
||||
|
||||
- **Status:** Proposed | Accepted | Deprecated | Superseded
|
||||
- **Context:** What is the issue that we're seeing that motivates this decision?
|
||||
- **Decision:** What is the change that we're proposing and/or doing?
|
||||
- **Consequences:** What becomes easier or more difficult to do because of this change?
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
New ADRs should be added when:
|
||||
|
||||
- A significant technology, framework, or tool choice is made
|
||||
- A structural or organizational pattern is established or changed
|
||||
- A decision constrains future development options
|
||||
- A previous ADR is superseded or deprecated
|
||||
|
|
|
|||
|
|
@ -1,3 +1,120 @@
|
|||
# Architecture Overview
|
||||
|
||||
This section documents the Kell Creations platform using C4 modeling, PlantUML, and supporting technical documentation.
|
||||
|
||||
## Architecture Model
|
||||
|
||||
The platform architecture follows the C4 model with five distinct view types:
|
||||
|
||||
| Level | View | Purpose |
|
||||
| ----- | ---------------- | --------------------------------------------------------------- |
|
||||
| 1 | System Landscape | Highest-level view of the platform and its external environment |
|
||||
| 2 | System Context | Platform boundary with users and external systems |
|
||||
| 3 | Container | Major application and shared-service containers |
|
||||
| 4 | Component | Internal structure of each application container |
|
||||
| — | Dynamic | Cross-application workflow and sequence diagrams |
|
||||
| — | Deployment | Runtime topology and infrastructure |
|
||||
|
||||
## Architecture Artifacts
|
||||
|
||||
All architecture diagrams are maintained as code using PlantUML (C4-PlantUML) in `architecture/workspace/`. Documentation pages in `docs/architecture/` provide narrative context, included elements, and notes for each diagram.
|
||||
|
||||
### Current inventory
|
||||
|
||||
| Category | Count | Files |
|
||||
| ---------------- | ------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| System Landscape | 1 | `system-landscape.puml` |
|
||||
| System Context | 1 | `context-kellcreations-platform.puml` |
|
||||
| Container views | 6 | Platform containers, enterprise services, data, identity & access, integration, audit |
|
||||
| Component views | 5 | Inventory, MRP, WordPress management, social media, financial analysis |
|
||||
| Dynamic views | 5 | Order-to-fulfillment, product publishing, social campaign, inventory-to-production, policy lifecycle |
|
||||
| Deployment views | 1 | Production deployment |
|
||||
| **Total** | **19** | All maintained as PlantUML in `architecture/workspace/` |
|
||||
|
||||
## Analysis Findings
|
||||
|
||||
!!! info "Last analyzed: 2026-05-22"
|
||||
|
||||
The following findings were identified through a full review of the architecture documentation, PlantUML sources, and cross-referencing with the application codebase.
|
||||
|
||||
### Confirmed strengths
|
||||
|
||||
1. **Complete C4 coverage** — All four C4 levels are documented with consistent structure across views
|
||||
2. **Architecture as code** — All 19 diagrams are maintained as PlantUML source, rendered via CI/CD pipeline
|
||||
3. **Traceability index** — A comprehensive cross-reference maps business domains to architecture artifacts, workflows, and governance documents
|
||||
4. **Enterprise service backbone** — Shared services (authentication, data, integration, audit, notifications) are architecturally defined and consistently referenced across all application component views
|
||||
5. **CI/CD integration** — PlantUML rendering is integrated into the publish and validate workflows with automatic SVG generation
|
||||
|
||||
### Issues identified
|
||||
|
||||
#### 1. Enterprise Data Architecture — wrong diagram reference
|
||||
|
||||
**Severity:** High
|
||||
|
||||
The file `docs/architecture/containers/enterprise-data-architecture.md` references the wrong SVG image:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
This should reference `enterprise-data-architecture.svg` instead. The corresponding PlantUML source file exists (`enterprise-data-architecture.puml`) but the documentation page displays the enterprise services diagram instead of the data architecture diagram.
|
||||
|
||||
**Recommendation:** Fix the image reference to point to the correct SVG.
|
||||
|
||||
#### 2. Missing diagram source references
|
||||
|
||||
**Severity:** Low
|
||||
|
||||
The following documentation pages do not include a "Diagram Source" section pointing to their PlantUML file:
|
||||
|
||||
- `docs/architecture/system-landscape.md` — missing reference to `architecture/workspace/system-landscape.puml`
|
||||
- `docs/architecture/containers/platform-containers.md` — missing reference to `architecture/workspace/containers-platform.puml`
|
||||
- `docs/architecture/components/inventory.md` — missing reference to `architecture/workspace/components-inventory.puml`
|
||||
|
||||
All other architecture documentation pages include this reference consistently. These three should be updated for consistency.
|
||||
|
||||
#### 3. ADR index is empty
|
||||
|
||||
**Severity:** Medium
|
||||
|
||||
The Architecture Decision Record (ADR) index exists at `docs/architecture/decisions/index.md` but contains no entries. Several significant architectural decisions have already been made and should be captured:
|
||||
|
||||
**Recommended initial ADRs:**
|
||||
|
||||
| ADR | Decision |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| ADR-001 | Use C4 model with PlantUML as architecture-as-code standard |
|
||||
| ADR-002 | Use Flutter monorepo with shared packages for cross-platform applications |
|
||||
| ADR-003 | Use `--dart-define` for runtime environment selection (fake vs. real backends) |
|
||||
| ADR-004 | Use MkDocs Material with Forgejo CI/CD for documentation publishing |
|
||||
| ADR-005 | Use abstract service composition pattern in `core` for cross-platform app shells |
|
||||
| ADR-006 | Maintain policy repository with controlled lifecycle and YAML front matter metadata |
|
||||
| ADR-007 | Use Forgejo Actions with dedicated runners for CI/CD pipelines |
|
||||
|
||||
#### 4. Architecture-to-implementation gap
|
||||
|
||||
**Severity:** Medium
|
||||
|
||||
The architecture documentation describes rich capabilities across all five business domains, but actual implementation maturity varies significantly. Only `feature_wordpress` has substantial implementation. This creates a risk that architecture documentation may be perceived as describing current state rather than target state.
|
||||
|
||||
**Recommendation:** Add a clear "implementation status" or "maturity" indicator to each component view documentation page, or maintain a single cross-reference (already partially addressed by the feature maturity matrix in the master development brief).
|
||||
|
||||
#### 5. No architecture for the documentation platform itself
|
||||
|
||||
**Severity:** Low
|
||||
|
||||
The deployment view covers the documentation publishing infrastructure, but there is no component-level architecture for the documentation platform itself (MkDocs, PlantUML server, Forgejo runners, published site). Given the CI/CD complexity already documented in the operations section, a lightweight component view could help operational clarity.
|
||||
|
||||
#### 6. Images directory not committed to Git
|
||||
|
||||
**Severity:** Informational
|
||||
|
||||
The `docs/images/` directory does not exist in the repository. SVG images are generated at CI/CD time by the publish and validate workflows. This is by design — the workflows create the directory, render PlantUML to SVG, and copy files before the MkDocs build. This is architecturally sound but means local MkDocs builds without running the PlantUML render step will have broken image references.
|
||||
|
||||
**Recommendation:** Document this behavior in the architecture workflow documentation so new contributors understand that `docs/images/` is a build artifact, not a committed directory.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Architecture Principles](principles.md)
|
||||
- [Traceability Index](traceability-index.md)
|
||||
- [ADR Index](decisions/index.md)
|
||||
|
|
|
|||
|
|
@ -8,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.
|
||||
|
||||
## Diagram Source
|
||||
|
||||
The source for this diagram is maintained as architecture code in:
|
||||
|
||||
`architecture/workspace/system-landscape.puml`
|
||||
|
||||
## Diagram
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -25,49 +25,49 @@ Use this page to answer questions such as:
|
|||
|
||||
## 1. Enterprise Architecture Traceability
|
||||
|
||||
| Enterprise View | File | Supports Domains |
|
||||
|---|---|---|
|
||||
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
|
||||
| Platform Context | `docs/architecture/context/platform.md` | All domains |
|
||||
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
|
||||
| Enterprise Shared Services | `docs/architecture/containers/enterprise-services.md` | All domains |
|
||||
| Enterprise Data Architecture | `docs/architecture/containers/enterprise-data-architecture.md` | Inventory, MRP, WordPress, Social Media, Financial Analysis, Policy Repository |
|
||||
| Enterprise Identity & Access Architecture | `docs/architecture/containers/enterprise-identity-access-architecture.md` | All application and governance domains |
|
||||
| Enterprise View | File | Supports Domains |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
|
||||
| Platform Context | `docs/architecture/context/platform.md` | All domains |
|
||||
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
|
||||
| Enterprise Shared Services | `docs/architecture/containers/enterprise-services.md` | All domains |
|
||||
| Enterprise Data Architecture | `docs/architecture/containers/enterprise-data-architecture.md` | Inventory, MRP, WordPress, Social Media, Financial Analysis, Policy Repository |
|
||||
| Enterprise Identity & Access Architecture | `docs/architecture/containers/enterprise-identity-access-architecture.md` | All application and governance domains |
|
||||
| Enterprise Integration & Orchestration Architecture | `docs/architecture/containers/enterprise-integration-orchestration-architecture.md` | WordPress, Social Media, MRP, Inventory, Financial Analysis, Policy Repository |
|
||||
| Enterprise Audit, Logging & Compliance Architecture | `docs/architecture/containers/enterprise-audit-logging-compliance-architecture.md` | All application and governance domains |
|
||||
| Deployment Architecture | `docs/architecture/deployment/production.md` | All domains |
|
||||
| Enterprise Audit, Logging & Compliance Architecture | `docs/architecture/containers/enterprise-audit-logging-compliance-architecture.md` | All application and governance domains |
|
||||
| Deployment Architecture | `docs/architecture/deployment/production.md` | All domains |
|
||||
|
||||
---
|
||||
|
||||
## 2. Application Component Traceability
|
||||
|
||||
| Application Domain | Component View | File | Related Enterprise Views |
|
||||
|---|---|---|---|
|
||||
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
|
||||
| Craft Manufacturing / MRP | Craft Manufacturing / MRP Components | `docs/architecture/components/mrp.md` | Shared Services, Data, Identity & Access, Audit, Integration |
|
||||
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
|
||||
| Social Media Management | Social Media Management Components | `docs/architecture/components/social-media-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
|
||||
| Financial Analysis | Financial Analysis Components | `docs/architecture/components/financial-analysis.md` | Shared Services, Data, Identity & Access, Audit |
|
||||
| Application Domain | Component View | File | Related Enterprise Views |
|
||||
| ------------------------- | ------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
|
||||
| Craft Manufacturing / MRP | Craft Manufacturing / MRP Components | `docs/architecture/components/mrp.md` | Shared Services, Data, Identity & Access, Audit, Integration |
|
||||
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
|
||||
| Social Media Management | Social Media Management Components | `docs/architecture/components/social-media-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
|
||||
| Financial Analysis | Financial Analysis Components | `docs/architecture/components/financial-analysis.md` | Shared Services, Data, Identity & Access, Audit |
|
||||
|
||||
---
|
||||
|
||||
## 3. Dynamic Workflow Traceability
|
||||
|
||||
| Workflow | File | Primary Domains | Related Component Views |
|
||||
|---|---|---|---|
|
||||
| Order to Fulfillment | `docs/architecture/dynamic/order-to-fulfillment.md` | WordPress, Inventory, MRP, Financial Analysis | Inventory, MRP, WordPress, Financial Analysis |
|
||||
| Product Publishing Workflow | `docs/architecture/dynamic/product-publishing.md` | WordPress Management, Shared Data | WordPress Management |
|
||||
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
|
||||
| Inventory-to-Production Workflow | `docs/architecture/dynamic/inventory-to-production.md` | Inventory, MRP | Inventory, MRP |
|
||||
| Policy Approval and Retirement Workflow | `docs/architecture/dynamic/policy-approval-retirement.md` | Governance, Policy Repository | Enterprise Audit, Identity & Access, Policy Repository |
|
||||
| Workflow | File | Primary Domains | Related Component Views |
|
||||
| --------------------------------------- | --------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------ |
|
||||
| Order to Fulfillment | `docs/architecture/dynamic/order-to-fulfillment.md` | WordPress, Inventory, MRP, Financial Analysis | Inventory, MRP, WordPress, Financial Analysis |
|
||||
| Product Publishing Workflow | `docs/architecture/dynamic/product-publishing.md` | WordPress Management, Shared Data | WordPress Management |
|
||||
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
|
||||
| Inventory-to-Production Workflow | `docs/architecture/dynamic/inventory-to-production.md` | Inventory, MRP | Inventory, MRP |
|
||||
| Policy Approval and Retirement Workflow | `docs/architecture/dynamic/policy-approval-retirement.md` | Governance, Policy Repository | Enterprise Audit, Identity & Access, Policy Repository |
|
||||
|
||||
---
|
||||
|
||||
## 4. Governance Document Traceability
|
||||
|
||||
| Governance Document | File | Governs Domains | Related Workflows / Views |
|
||||
|---|---|---|---|
|
||||
| Document Control Policy | `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
|
||||
| Governance Document | File | Governs Domains | Related Workflows / Views |
|
||||
| -------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
|
||||
| Document Control Policy | `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
|
||||
| Policy Review and Retirement Procedure | `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
|
||||
|
||||
---
|
||||
|
|
@ -75,6 +75,7 @@ Use this page to answer questions such as:
|
|||
## 5. Domain-to-Artifact Mapping
|
||||
|
||||
### Inventory
|
||||
|
||||
- Component View:
|
||||
- `docs/architecture/components/inventory.md`
|
||||
- Dynamic Workflows:
|
||||
|
|
@ -88,6 +89,7 @@ Use this page to answer questions such as:
|
|||
- Integration & Orchestration
|
||||
|
||||
### Craft Manufacturing / MRP
|
||||
|
||||
- Component View:
|
||||
- `docs/architecture/components/mrp.md`
|
||||
- Dynamic Workflows:
|
||||
|
|
@ -101,6 +103,7 @@ Use this page to answer questions such as:
|
|||
- Integration & Orchestration
|
||||
|
||||
### WordPress Management
|
||||
|
||||
- Component View:
|
||||
- `docs/architecture/components/wordpress-management.md`
|
||||
- Dynamic Workflows:
|
||||
|
|
@ -115,6 +118,7 @@ Use this page to answer questions such as:
|
|||
- Deployment
|
||||
|
||||
### Social Media Management
|
||||
|
||||
- Component View:
|
||||
- `docs/architecture/components/social-media-management.md`
|
||||
- Dynamic Workflows:
|
||||
|
|
@ -127,6 +131,7 @@ Use this page to answer questions such as:
|
|||
- Audit & Compliance
|
||||
|
||||
### Financial Analysis
|
||||
|
||||
- Component View:
|
||||
- `docs/architecture/components/financial-analysis.md`
|
||||
- Dynamic Workflows:
|
||||
|
|
@ -138,6 +143,7 @@ Use this page to answer questions such as:
|
|||
- Audit & Compliance
|
||||
|
||||
### Governance / Policy Repository
|
||||
|
||||
- Governance Documents:
|
||||
- `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md`
|
||||
- `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md`
|
||||
|
|
@ -155,20 +161,28 @@ Use this page to answer questions such as:
|
|||
|
||||
The following areas are strong candidates for future documentation:
|
||||
|
||||
!!! info "Last analyzed: 2026-05-22"
|
||||
|
||||
### Additional component views
|
||||
|
||||
- Policy Repository internal components
|
||||
- Notification Service internal components
|
||||
- API Orchestrator internal components
|
||||
- Shared Data Repository internal components
|
||||
- Documentation Platform internal components (MkDocs, PlantUML server, Forgejo runners)
|
||||
|
||||
### Additional dynamic workflows
|
||||
|
||||
- Financial close / reporting workflow
|
||||
- Website change management workflow
|
||||
- Exception handling workflow
|
||||
- Marketing campaign analytics workflow
|
||||
- Product image / media synchronization workflow
|
||||
- Local development setup workflow
|
||||
- Incident response workflow
|
||||
|
||||
### Additional governance documents
|
||||
|
||||
- Access Control Policy
|
||||
- Social Media Publishing Standard
|
||||
- Product Publishing Procedure
|
||||
|
|
@ -178,6 +192,29 @@ The following areas are strong candidates for future documentation:
|
|||
- Website Change Management Procedure
|
||||
- Exception Management Standard
|
||||
|
||||
### Architecture decisions
|
||||
|
||||
- No formal ADRs exist; 7 candidate ADRs identified — see [ADR Index](decisions/index.md)
|
||||
|
||||
### Standards documents
|
||||
|
||||
- No formal standards documents exist; 10 candidate standards identified — see [Standards Overview](../standards/index.md)
|
||||
|
||||
### Operational procedures
|
||||
|
||||
- No operational runbooks exist; 5 candidate procedures identified — see [Operations Overview](../operations/index.md)
|
||||
|
||||
### Integration documentation
|
||||
|
||||
- Only WooCommerce integration is implemented; 5 additional integrations architecturally defined — see [Integrations Overview](../integrations/index.md)
|
||||
|
||||
### Documentation quality issues
|
||||
|
||||
- Enterprise Data Architecture page references wrong SVG diagram — **fixed 2026-05-22**
|
||||
- Three architecture pages were missing "Diagram Source" references — **fixed 2026-05-22**
|
||||
- Policy register CSVs are empty (header only) despite 2 active governance documents
|
||||
- `feature_orders` package is missing standard scaffolding files (`.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md`) present in all other packages
|
||||
|
||||
---
|
||||
|
||||
## 7. Maintenance Rules
|
||||
|
|
|
|||
|
|
@ -1,3 +1,73 @@
|
|||
# Brand Standards
|
||||
|
||||
This section maps the Kell Creations brand guide into reusable documentation and design standards.
|
||||
|
||||
## Current State
|
||||
|
||||
No formal brand documentation has been published yet. Brand-related design elements exist in the `design_system` Flutter package but have not been documented as brand standards.
|
||||
|
||||
## Existing Brand Elements
|
||||
|
||||
### Design system implementation
|
||||
|
||||
The `design_system` package (`kell_creations_apps/packages/design_system/`) contains the following brand-relevant components:
|
||||
|
||||
| Element | Implementation | Notes |
|
||||
| -------------- | --------------- | ------------------------------------------------------- |
|
||||
| Color palette | `KcColors` | Brand colors defined as Flutter constants |
|
||||
| Spacing system | `KcSpacing` | Consistent spacing scale |
|
||||
| Typography | `KcTypography` | Full Material 3 text style hierarchy |
|
||||
| Theme | `KcTheme` | Composed theme applying colors, typography, and spacing |
|
||||
| Breakpoints | `KcBreakpoints` | Responsive layout breakpoints for web and mobile |
|
||||
|
||||
### Reusable brand widgets
|
||||
|
||||
| Widget | Purpose |
|
||||
| ----------------- | ------------------------------------ |
|
||||
| `KcCard` | Standard content card |
|
||||
| `KcStatusChip` | Status indicator chip |
|
||||
| `KcSectionHeader` | Section heading with optional action |
|
||||
| `KcSummaryCard` | Dashboard summary card |
|
||||
| `KcEmptyState` | Empty/placeholder state display |
|
||||
| `KcLoadingState` | Loading indicator |
|
||||
| `KcErrorState` | Error display with optional retry |
|
||||
|
||||
## Recommendations
|
||||
|
||||
!!! info "Last analyzed: 2026-05-22"
|
||||
|
||||
### 1. Document color palette and usage guidelines
|
||||
|
||||
**Priority:** Medium
|
||||
|
||||
The `KcColors` class defines the color palette, but there is no documentation specifying:
|
||||
|
||||
- Primary, secondary, and accent color usage
|
||||
- Accessibility contrast requirements
|
||||
- Color usage in different contexts (backgrounds, text, borders, status indicators)
|
||||
|
||||
### 2. Document typography scale and usage
|
||||
|
||||
**Priority:** Medium
|
||||
|
||||
`KcTypography` defines the full type hierarchy but lacks documentation on:
|
||||
|
||||
- When to use each text style (headings, body, captions, labels)
|
||||
- Font family and weight guidelines
|
||||
- Line height and letter spacing rationale
|
||||
|
||||
### 3. Create a visual brand style guide
|
||||
|
||||
**Priority:** Low
|
||||
|
||||
A visual reference showing the brand elements in use would help maintain consistency across web and mobile platforms. This could be a dedicated MkDocs page with color swatches, typography samples, and widget examples.
|
||||
|
||||
### 4. Document logo and asset guidelines
|
||||
|
||||
**Priority:** Low
|
||||
|
||||
No logo or visual asset documentation exists. If brand logos, icons, or imagery guidelines exist, they should be documented here.
|
||||
|
||||
## Relationship to Design System
|
||||
|
||||
The `design_system` package is the programmatic implementation of brand standards. Changes to brand standards should be reflected in the design system, and vice versa. The design system has full test coverage (41 tests, 100% line coverage as of 2026-05-22).
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- main baseline updated through: post-write-consistency
|
||||
- main baseline commit: `7acff83` (2026-04-11)
|
||||
- next branch: feat/publishing-ux-hardening
|
||||
- current stage: Stage 2 — Web application operational hardening
|
||||
- main baseline updated through: bulk-status-actions (Stage 7A complete)
|
||||
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/integrations-contracts (Stage 8A)
|
||||
- current stage: Stage 7A complete — deciding next slice
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -44,7 +43,8 @@
|
|||
|
||||
### feat/publishing-ux-hardening
|
||||
|
||||
- status: in progress (Stage 2B)
|
||||
- status: merged to main
|
||||
- commit: `b81016d`
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
|
|
@ -55,3 +55,208 @@
|
|||
- tests: passed (247/247 feature_wordpress)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/multi-select-groundwork
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added multi-selection state (`toggleMultiSelect`, `clearMultiSelection`, `selectAllVisible`, `isMultiSelected`, `multiSelectedIds`, `multiSelectedCount`)
|
||||
- `feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart` — added optional leading checkbox for multi-select mode
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_MultiSelectBar` with selected-count display, Select All, and Clear actions
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 15 new multi-select tests
|
||||
- tests: passed (262/262 feature_wordpress)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/list-efficiency-improvements
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `ListDensity` enum, compact/standard view toggle, staleness detection (`isStale()`, `staleCount()` with 30-day threshold), keyboard navigation (`selectNextDraft`/`selectPreviousDraft` with wrapping)
|
||||
- `feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart` — added compact two-row layout variant, description snippet in standard card, stale indicator icon, `Flexible` overflow handling
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — wired `Focus`+`onKeyEvent` for arrow keys and density toggle button
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 32 new tests
|
||||
- tests: passed (294/294 feature_wordpress, 5/5 kell_web dashboard)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/design-system-shared-widgets
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `design_system/lib/design_system.dart` — expanded barrel exports with typography, layout, and 5 new widgets
|
||||
- `design_system/lib/src/theme/kc_typography.dart` — new shared typography scale (KcTypography) with full Material 3 text style hierarchy and `applyKcTypography()` helper
|
||||
- `design_system/lib/src/theme/kc_theme.dart` — updated to use `KcTypography.applyKcTypography()` instead of inline text styles
|
||||
- `design_system/lib/src/layout/kc_breakpoints.dart` — new responsive breakpoint utilities (KcBreakpoints) with compact/medium/expanded/large queries and grid column helper
|
||||
- `design_system/lib/src/widgets/kc_empty_state.dart` — migrated from kell_web EmptyStatePanel as KcEmptyState
|
||||
- `design_system/lib/src/widgets/kc_section_header.dart` — migrated from kell_web SectionHeader as KcSectionHeader
|
||||
- `design_system/lib/src/widgets/kc_summary_card.dart` — migrated from kell_web SummaryCard as KcSummaryCard
|
||||
- `design_system/lib/src/widgets/kc_loading_state.dart` — new shared loading state widget
|
||||
- `design_system/lib/src/widgets/kc_error_state.dart` — new shared error state widget with optional retry
|
||||
- `design_system/test/design_system_test.dart` — expanded from 3 to 41 tests covering all new widgets, typography, breakpoints, colors, spacing
|
||||
- `kell_web/lib/pages/dashboard_page.dart` — updated imports to use design_system widgets directly (KcSectionHeader, KcSummaryCard, KcEmptyState)
|
||||
- `kell_web/lib/shell/widgets/empty_state_panel.dart` — replaced with backward-compatible typedef re-export
|
||||
- `kell_web/lib/shell/widgets/section_header.dart` — replaced with backward-compatible typedef re-export
|
||||
- `kell_web/lib/shell/widgets/summary_card.dart` — replaced with backward-compatible typedef re-export
|
||||
- tests: passed (41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web)
|
||||
- analyze: passed (dart analyze — no issues found in design_system and kell_web)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/shared-composition-pattern
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `core/lib/src/app/` — extracted shared composition abstractions (KcAppConfig, KcAppEnvironment, KcAppServices, KcServiceFactory, KcBootstrap, KcAppScope)
|
||||
- `core/lib/core.dart` — expanded barrel exports for app composition
|
||||
- `core/test/core_test.dart` — added 20 tests for shared composition abstractions
|
||||
- `kell_web/` — updated to use core composition abstractions
|
||||
- tests: passed (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/flutter-cicd
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `.forgejo/workflows/flutter-analyze.yml` — new Forgejo Actions workflow for dart analyze on all 8 packages/apps
|
||||
- `.forgejo/workflows/flutter-test.yml` — new Forgejo Actions workflow for flutter test per package with aggregate pass/fail summary
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — new local CI helper script with optional --analyze flag
|
||||
- `kell_creations_apps/tools/README.md` — documents scripts and CI workflow inventory
|
||||
- tests: passed (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web — 379 total)
|
||||
- analyze: passed (dart analyze — no issues found across all 8 packages/apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/test-coverage-visibility
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-22
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `.forgejo/workflows/flutter-test.yml` — enhanced to run flutter test --coverage, parse lcov.info, report per-package line coverage percentages in aggregate summary table
|
||||
- `kell_creations_apps/tools/collect_coverage.sh` — new local coverage helper script with summary table
|
||||
- `kell_creations_apps/tools/README.md` — documented collect_coverage.sh script
|
||||
- `.gitignore` — added coverage/ directories
|
||||
- `docs/development/master_development_brief.md` — marked Stage 4D complete, documented baseline coverage table, updated next branch to Stage 5A, partially resolved improvement #5
|
||||
- tests: passed (20/20 core, 41/41 design_system, 294/294 feature_wordpress, 24/24 kell_web — 379 total)
|
||||
- analyze: passed
|
||||
- coverage baseline: core 85.7%, design_system 100.0%, feature_wordpress 84.7%, kell_web 54.1%, overall 78.4%
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-app-shell
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-28
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `kell_mobile/pubspec.yaml` — replaced default template dependencies with shared packages (`core`, `design_system`, `feature_inventory`, `feature_orders`, `feature_policy`, `feature_wordpress`); SDK constraint corrected to `^3.11.0`
|
||||
- `kell_mobile/lib/main.dart` — replaced counter template with `KcBootstrap` entry point using `--dart-define` environment variables
|
||||
- `kell_mobile/lib/app.dart` — new `KellMobileApp` widget with `KcAppScope<MobileAppServices>`, `KcTheme`, and environment badge
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — new `MobileAppServices` extending `KcAppServices` with `fake()` and `wp()` factory constructors
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — new `MobileShell` with 5-tab `NavigationBar` (Dashboard, Inventory, Orders, Publishing, More) and `IndexedStack` body
|
||||
- `kell_mobile/lib/dashboard/domain/dashboard_summary.dart` — shared `DashboardSummary` value object with `fromData()` and `empty()` constructors
|
||||
- `kell_mobile/lib/dashboard/application/get_dashboard_summary.dart` — use case aggregating inventory, orders, and publishing repositories
|
||||
- `kell_mobile/lib/dashboard/application/dashboard_controller.dart` — `ChangeNotifier` controller with loading/error/summary state
|
||||
- `kell_mobile/lib/pages/dashboard_page.dart` — mobile-optimized dashboard with `GridView` summary cards using design system widgets
|
||||
- `kell_mobile/lib/pages/finance_placeholder_page.dart` — placeholder page for Finance tab
|
||||
- `kell_mobile/lib/pages/integrations_placeholder_page.dart` — placeholder page for Integrations tab
|
||||
- `kell_mobile/test/widget_test.dart` — 6 widget tests covering shell loading, summary cards, environment badge, navigation bar, tab switching, and More menu
|
||||
- 13 other `pubspec.yaml` files — SDK constraint corrected from `^3.11.4` to `^3.11.0` across all packages
|
||||
- tests: passed (6/6 kell_mobile, 24/24 kell_web, 294/294 feature_wordpress — all passing)
|
||||
- analyze: not yet run (SDK constraint fix was prerequisite)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-publishing-surface
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-29
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — expanded barrel exports with `ProductPublishingController`, `ProductSortField`, `ProductDraftCard`, `ProductPreviewPanel`, and all 5 use cases + snack bar helpers for mobile consumption
|
||||
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — new mobile-optimized publishing workspace with search bar, horizontal filter chips, sort dropdown, product count, compact card list, pull-to-refresh, and push navigation to detail page
|
||||
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — new full-screen product detail page wrapping shared `ProductPreviewPanel` with all narrow edit callbacks
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Products tab (case 2) switched from `ProductPublishingPage` to `MobilePublishingPage`; removed unused `feature_wordpress` import
|
||||
- `kell_mobile/test/widget_test.dart` — added 4 new mobile publishing surface tests (search bar, filter chips, product count, sort button) — 10 total kell_mobile tests
|
||||
- tests: passed (10/10 kell_mobile)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-feedback-polish
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-29
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — converted from stateless to StatefulWidget; added local controller listener for SnackBar feedback in detail page context; added confirmation dialogs for publish/move-to-draft actions; added haptic feedback (mediumImpact for status, lightImpact for field edits) on successful actions
|
||||
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — added `_detailPageActive` guard to suppress SnackBars when detail page is pushed (prevents invisible behind-route feedback); updated `_navigateToDetail` to set/clear the guard flag using Navigator.push().then()
|
||||
- `kell_mobile/test/widget_test.dart` — added 4 new Stage 6A tests: detail page navigation, confirmation dialog for status changes, product name in app bar, and back navigation returning to product list
|
||||
- tests: passed (14/14 kell_mobile)
|
||||
- analyze: passed (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/android-mobile-ux-hardening
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart` — removed `constraints: BoxConstraints()` and `padding: EdgeInsets.zero` overrides from 12 `IconButton`s (edit/save/cancel for name, price, description, category) to restore Material Design 48×48dp minimum touch targets; replaced fixed-width `SizedBox(width: 80)` on price edit `TextField` with `Expanded` for flexible layout on narrow mobile screens; added `tooltip: 'Edit price'` for consistency with other edit buttons
|
||||
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — wrapped `ProductPreviewPanel` in `SafeArea` to prevent content clipping under system UI on devices with notches/gesture bars
|
||||
- `feature_wordpress/test/widgets/product_preview_panel_test.dart` — added 6 new touch target tests: rendered size ≥ 48×48dp verification for edit name, edit price, edit category, edit description, save/cancel name buttons, and flexible price edit TextField width
|
||||
- tests: passed (300/300 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system — 379 total)
|
||||
- analyze: passed
|
||||
- brief updated: yes
|
||||
|
||||
### feat/bulk-status-actions
|
||||
|
||||
- status: merged to main
|
||||
- commit: `2c3ed3b`
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `BulkActionResult` value class with `successCount`, `failureCount`, `targetStatus`, `failedProductNames`, `totalCount`, `allSucceeded`; added `lastBulkActionResult`, `consumeBulkActionResult()`, and `bulkUpdateStatus()` method that processes selected products sequentially with per-row updating state, then reloads once and clears multi-selection
|
||||
- `feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart` — added `showBulkActionSnackBar()` helper with success/partial-failure/total-failure variants showing count summaries and up to 3 failed product names
|
||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_confirmBulkMoveToDraft()` confirmation dialog; updated `_MultiSelectBar` with `onBulkMoveToDraft` callback and "Move to Draft" `OutlinedButton.icon`; added bulk result listener in `_onControllerChanged`
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 11 new `bulkUpdateStatus` tests: no-op on empty selection, moves all to draft, sets result on all-success, clears multi-selection, clears updatingIds, handles mixed statuses, consume clears result, starts null, preserves preview selection, persists filter/sort, disposal safety
|
||||
- tests: passed (311/311 feature_wordpress, 24/24 kell_web — 335+ total)
|
||||
- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files)
|
||||
- brief updated: yes
|
||||
|
||||
### chore/analyze-cleanup-and-tracker-sync
|
||||
|
||||
- status: in progress
|
||||
- date: 2026-05-30
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — removed 3 unnecessary_import directives (get_product_drafts, product_publishing_controller, publish_product)
|
||||
- `feature_wordpress/test/widgets/product_draft_card_test.dart` — removed 1 unnecessary_import directive (product_draft_card)
|
||||
- `feature_wordpress/test/widgets/product_preview_panel_test.dart` — removed 1 unnecessary_import directive (product_preview_panel)
|
||||
- `feature_wordpress/test/widgets/publish_status_chip_test.dart` — removed 1 unnecessary_import directive (publish_status_chip)
|
||||
- `feature_wordpress/test/widgets/status_action_snack_bar_test.dart` — removed 2 unnecessary_import directives (product_publishing_controller, status_action_snack_bar)
|
||||
- `kell_mobile/test/widget_test.dart` — removed unused `productCards` local variable
|
||||
- `docs/development/build_execution_tracker.md` — updated Stage 7A to merged, synced current status
|
||||
- `docs/development/master_development_brief.md` — updated next recommended branch, validation state, test counts
|
||||
- tests: passed (311/311 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system, 20/20 core — 410 total)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,3 +1,49 @@
|
|||
# Kell Creations Platform
|
||||
|
||||
This site is the central documentation portal for Kell Creations applications, services, architecture, policies, and operating processes.
|
||||
|
||||
## Platform Health Summary
|
||||
|
||||
This section provides an at-a-glance assessment of the platform's documentation, architecture, application, and governance maturity. It is maintained through periodic full-project analysis.
|
||||
|
||||
!!! info "Last reviewed: 2026-05-22"
|
||||
|
||||
### Documentation maturity
|
||||
|
||||
| Area | Status | Notes |
|
||||
| ----------------------- | ------------------- | ------------------------------------------------------------------- |
|
||||
| Architecture (C4 model) | ✅ Comprehensive | All C4 levels documented with PlantUML sources |
|
||||
| Architecture decisions | ⚠️ Empty | ADR index exists but no decisions recorded |
|
||||
| Governance policies | ✅ Foundation set | 2 active governance documents, templates complete |
|
||||
| Operations runbooks | ⚠️ Minimal | CI/CD and architecture workflow documented; no operational runbooks |
|
||||
| Standards | ⚠️ Placeholder only | Index page exists with no content |
|
||||
| Integrations | ⚠️ Placeholder only | Index page exists with no content |
|
||||
| Brand | ⚠️ Placeholder only | Index page exists with no content |
|
||||
| Development planning | ✅ Comprehensive | Master brief, tracker, composition strategy documented |
|
||||
|
||||
### Application maturity
|
||||
|
||||
| Package | Implementation | Tests | Production readiness |
|
||||
| ------------------- | ---------------- | ------- | -------------------- |
|
||||
| `feature_wordpress` | ✅ Complete | 294 | Production-ready |
|
||||
| `feature_inventory` | ✅ Fake-only MVP | Minimal | Fake-only |
|
||||
| `feature_orders` | ✅ Fake-only MVP | Some | Fake-only |
|
||||
| `feature_policy` | ✅ Fake-only MVP | Minimal | Fake-only |
|
||||
| `feature_finance` | ❌ Stub | None | Scaffolded only |
|
||||
| `feature_mrp` | ❌ Stub | None | Scaffolded only |
|
||||
| `feature_social` | ❌ Stub | None | Scaffolded only |
|
||||
| `auth` | ❌ Stub | None | Scaffolded only |
|
||||
| `data` | ❌ Stub | None | Scaffolded only |
|
||||
| `integrations` | ❌ Stub | None | Scaffolded only |
|
||||
|
||||
### Key recommendations
|
||||
|
||||
The following high-priority recommendations were identified through full-project analysis. Detailed findings are documented in [Architecture Overview](architecture/index.md), [Standards Overview](standards/index.md), [Operations Overview](operations/index.md), and the [Master Development Brief](development/master_development_brief.md).
|
||||
|
||||
1. **Fix broken diagram image reference** — The Enterprise Data Architecture page references the wrong SVG file
|
||||
2. **Populate empty documentation sections** — Standards, Integrations, and Brand sections need initial content
|
||||
3. **Record architecture decisions** — Start capturing ADRs for key decisions already made
|
||||
4. **Populate policy registers** — Active governance documents are not reflected in register CSVs
|
||||
5. **Expand CI/CD validation** — Add Markdown linting, link checking, and PlantUML validation
|
||||
6. **Add missing diagram source references** — Some architecture docs are missing PlantUML source paths
|
||||
7. **Plan infrastructure package activation** — `auth`, `data`, and `integrations` packages need activation roadmaps
|
||||
|
|
|
|||
|
|
@ -1,3 +1,71 @@
|
|||
# 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
|
||||
|
||||
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
|
||||
- Policy Review and Retirement Procedure
|
||||
|
||||
## Analysis Findings
|
||||
|
||||
!!! info "Last analyzed: 2026-05-22"
|
||||
|
||||
### Confirmed strengths
|
||||
|
||||
1. **Well-structured repository** — The `policies/` directory follows a clear lifecycle model with properly separated stages (drafts, review, active, retired)
|
||||
2. **Comprehensive templates** — Five document type templates are available (policy, procedure, standard, form, exception) with consistent YAML front matter
|
||||
3. **Domain categorization** — All lifecycle directories include subdirectories for 9 business domains (ecommerce, finance, governance, IT, manufacturing, marketing, operations, privacy, security)
|
||||
4. **Register structure** — Four CSV registers exist for policy tracking (policy register, control log, review calendar, exception register)
|
||||
5. **Active governance documents** — 2 foundational governance documents are published and active with complete metadata
|
||||
|
||||
### Issues identified
|
||||
|
||||
#### 1. Policy register CSVs are empty
|
||||
|
||||
**Severity:** High
|
||||
|
||||
All four register CSVs contain headers only with no data rows, despite 2 active governance documents existing:
|
||||
|
||||
- `policy-register.csv` — should have entries for `KC-POL-GOV-001` and `KC-PRO-GOV-001`
|
||||
- `document-control-log.csv` — should record the initial publication of both documents
|
||||
- `review-calendar.csv` — should show next review dates (both set to 2027-04-03)
|
||||
- `exception-register.csv` — may remain empty if no exceptions exist
|
||||
|
||||
**Recommendation:** Populate the registers immediately. The Document Control Policy (`KC-POL-GOV-001`) itself requires that active documents be referenced in the document register and control log.
|
||||
|
||||
#### 2. No evidence records exist
|
||||
|
||||
**Severity:** Medium
|
||||
|
||||
The `evidence/` directory has subdirectories for approvals, exceptions, retirement records, and reviews, but all are empty. The 2 active governance documents were approved on 2026-04-03 but no approval evidence is stored.
|
||||
|
||||
**Recommendation:** Create initial approval evidence records for the existing governance documents.
|
||||
|
||||
#### 3. Governance documents are only domain with active content
|
||||
|
||||
**Severity:** Informational
|
||||
|
||||
All other domain directories (ecommerce, finance, IT, manufacturing, marketing, operations, privacy, security) are empty across all lifecycle stages. This is expected given the platform's maturity but should be tracked for future planning.
|
||||
|
||||
**Recommendation:** Prioritize standards and procedures identified in the [Standards Overview](../standards/index.md) and [Operations Overview](../operations/index.md) as the next candidates for policy repository content.
|
||||
|
||||
## Notes
|
||||
|
||||
This section documents the governance layer that supports controlled business operations for Kell Creations. Future additions should include standards, procedures, guidelines, forms, and exception records across governance, operations, ecommerce, marketing, manufacturing, finance, IT, security, and privacy.
|
||||
|
|
@ -1,3 +1,50 @@
|
|||
# Standards Overview
|
||||
|
||||
This section contains technical, documentation, and business standards.
|
||||
|
||||
## Current State
|
||||
|
||||
No formal standards documents have been published yet. The following standards are applied implicitly through existing architecture, operations, and development documentation but have not been formalized as controlled standards documents.
|
||||
|
||||
## Recommended Standards
|
||||
|
||||
The following standards were identified through project analysis as candidates for formal documentation. Each aligns to the policy repository naming convention (`KC-STD-<DOMAIN>-<NUMBER>`) and can be created using the standard template at `policies/templates/standard-template.md`.
|
||||
|
||||
### Documentation standards
|
||||
|
||||
| Candidate ID | Title | Priority | Notes |
|
||||
| -------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| KC-STD-GOV-001 | Documentation Naming and Metadata Standard | High | Formalize the YAML front matter requirements already defined in the policy repository README |
|
||||
| KC-STD-GOV-002 | Architecture Documentation Standard | Medium | Formalize the C4 diagram documentation pattern (purpose, diagram source, diagram, elements, notes) already in use |
|
||||
| KC-STD-IT-001 | Markdown Authoring Standard | Low | Formalize linting rules (`.markdownlint.json`), heading conventions, and link patterns |
|
||||
|
||||
### Development standards
|
||||
|
||||
| Candidate ID | Title | Priority | Notes |
|
||||
| ------------- | ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| KC-STD-IT-002 | Flutter Package Structure Standard | High | Formalize the domain/application/data/presentation layer pattern used across feature packages |
|
||||
| KC-STD-IT-003 | Test Coverage Standard | Medium | Formalize baseline coverage expectations and reporting; currently visibility-only with no thresholds |
|
||||
| KC-STD-IT-004 | Git Branching and Merge Standard | Medium | Formalize the `feat/` branch-per-slice model and PR requirements documented in the development brief |
|
||||
| KC-STD-IT-005 | CI/CD Workflow Standard | Low | Formalize workflow naming, trigger patterns, and runner label conventions |
|
||||
|
||||
### Business standards
|
||||
|
||||
| Candidate ID | Title | Priority | Notes |
|
||||
| -------------- | -------------------------------- | -------- | ------------------------------------------------------ |
|
||||
| KC-STD-MKT-001 | Social Media Publishing Standard | Low | Referenced in the traceability index as a coverage gap |
|
||||
| KC-STD-ECM-001 | Product Publishing Standard | Low | Referenced in the traceability index as a coverage gap |
|
||||
| KC-STD-OPS-001 | Inventory Adjustment Standard | Low | Referenced in the traceability index as a coverage gap |
|
||||
|
||||
## Prioritization
|
||||
|
||||
Standards should be prioritized based on:
|
||||
|
||||
1. **Risk reduction** — Does this standard prevent inconsistency or errors?
|
||||
2. **Existing practice** — Is the standard already followed informally?
|
||||
3. **Scope of impact** — How many areas of the platform does it affect?
|
||||
|
||||
The documentation and development standards are recommended first because they codify practices already in use and reduce the risk of drift as the platform grows.
|
||||
|
||||
## Relationship to Policies and Procedures
|
||||
|
||||
Standards define measurable requirements and constraints. They are governed by the Document Control Policy (`KC-POL-GOV-001`) and follow the same controlled lifecycle (draft → review → active → retired).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'shell/mobile_shell.dart';
|
||||
|
||||
/// Root widget for the Kell Creations mobile application.
|
||||
///
|
||||
/// Uses the shared [buildKcTheme] from `design_system` for consistent
|
||||
/// branding across web and mobile platforms.
|
||||
class KellMobileApp extends StatelessWidget {
|
||||
const KellMobileApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Kell Creations',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildKcTheme(),
|
||||
home: const MobileShell(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
/// Holds the concrete service implementations used by `kell_mobile`.
|
||||
///
|
||||
/// Extends [KcAppServices] from the shared `core` package so that the
|
||||
/// generic [KcBootstrap] and [KcAppScope] infrastructure can work with
|
||||
/// this app's specific service set.
|
||||
///
|
||||
/// Mirrors the same service composition as `kell_web`'s `AppServices`,
|
||||
/// ensuring both platforms share identical business/domain logic.
|
||||
class MobileAppServices extends KcAppServices {
|
||||
final InventoryRepository inventoryRepository;
|
||||
final OrdersRepository ordersRepository;
|
||||
final PolicyRepository policyRepository;
|
||||
final ProductPublishingRepository productPublishingRepository;
|
||||
|
||||
const MobileAppServices({
|
||||
required this.inventoryRepository,
|
||||
required this.ordersRepository,
|
||||
required this.policyRepository,
|
||||
required this.productPublishingRepository,
|
||||
});
|
||||
|
||||
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||
factory MobileAppServices.fake() {
|
||||
return MobileAppServices(
|
||||
inventoryRepository: FakeInventoryRepository(),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: FakeProductPublishingRepository(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a [MobileAppServices] with a real WooCommerce-backed product
|
||||
/// repository. Other repositories remain fake until their backends are
|
||||
/// ready.
|
||||
factory MobileAppServices.wordpress({
|
||||
required String siteUrl,
|
||||
required String consumerKey,
|
||||
required String consumerSecret,
|
||||
}) {
|
||||
final apiClient = WooCommerceApiClient(
|
||||
siteUrl: siteUrl,
|
||||
consumerKey: consumerKey,
|
||||
consumerSecret: consumerSecret,
|
||||
);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: FakeInventoryRepository(),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||
static KcServiceFactory<MobileAppServices> get serviceFactory {
|
||||
return KcServiceFactory<MobileAppServices>(
|
||||
createFake: () => MobileAppServices.fake(),
|
||||
createWordPress: (config) => MobileAppServices.wordpress(
|
||||
siteUrl: config.wcSiteUrl,
|
||||
consumerKey: config.wcConsumerKey,
|
||||
consumerSecret: config.wcConsumerSecret,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../domain/dashboard_summary.dart';
|
||||
import 'get_dashboard_summary.dart';
|
||||
|
||||
/// Controller that manages the dashboard summary state.
|
||||
///
|
||||
/// Follows the same [ChangeNotifier] pattern used by other feature
|
||||
/// controllers. Mirrors `kell_web`'s equivalent controller.
|
||||
class DashboardController extends ChangeNotifier {
|
||||
final GetDashboardSummary _getDashboardSummary;
|
||||
|
||||
DashboardController(this._getDashboardSummary);
|
||||
|
||||
bool isLoading = false;
|
||||
DashboardSummary summary = DashboardSummary.empty;
|
||||
Object? error;
|
||||
|
||||
/// Loads the aggregated dashboard summary from all repositories.
|
||||
Future<void> load() async {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
summary = await _getDashboardSummary();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
import '../domain/dashboard_summary.dart';
|
||||
|
||||
/// Use case: fetches data from all three repositories and returns an
|
||||
/// aggregated [DashboardSummary].
|
||||
///
|
||||
/// This lives in the app layer (not in a feature package) because it
|
||||
/// crosses feature boundaries. Mirrors `kell_web`'s equivalent use case.
|
||||
class GetDashboardSummary {
|
||||
final InventoryRepository inventoryRepository;
|
||||
final ProductPublishingRepository productPublishingRepository;
|
||||
final OrdersRepository ordersRepository;
|
||||
|
||||
GetDashboardSummary({
|
||||
required this.inventoryRepository,
|
||||
required this.productPublishingRepository,
|
||||
required this.ordersRepository,
|
||||
});
|
||||
|
||||
Future<DashboardSummary> call() async {
|
||||
final results = await Future.wait([
|
||||
inventoryRepository.getInventoryItems(),
|
||||
productPublishingRepository.getProductDrafts(),
|
||||
ordersRepository.getOrders(),
|
||||
]);
|
||||
|
||||
return DashboardSummary.fromData(
|
||||
inventoryItems: results[0] as List<InventoryItem>,
|
||||
productDrafts: results[1] as List<ProductDraft>,
|
||||
orders: results[2] as List<Order>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
/// Aggregated summary data displayed on the mobile dashboard.
|
||||
///
|
||||
/// This is an app-level value object that composes data from multiple
|
||||
/// feature-package repositories without leaking domain logic back into
|
||||
/// those packages.
|
||||
///
|
||||
/// Mirrors the same domain model as `kell_web`'s `DashboardSummary`.
|
||||
class DashboardSummary {
|
||||
/// Total number of inventory items.
|
||||
final int totalProducts;
|
||||
|
||||
/// Items with [InventoryStatus.inStock].
|
||||
final int inStock;
|
||||
|
||||
/// Items with [InventoryStatus.lowStock].
|
||||
final int lowStock;
|
||||
|
||||
/// Items with [InventoryStatus.outOfStock].
|
||||
final int outOfStock;
|
||||
|
||||
/// Product drafts with [PublishStatus.draft].
|
||||
final int draftProducts;
|
||||
|
||||
/// Total number of orders.
|
||||
final int totalOrders;
|
||||
|
||||
/// Orders with [OrderStatus.pending].
|
||||
final int pendingOrders;
|
||||
|
||||
/// Orders with [OrderStatus.processing] or [OrderStatus.shipped].
|
||||
final int activeOrders;
|
||||
|
||||
/// Revenue from delivered orders.
|
||||
final double deliveredRevenue;
|
||||
|
||||
const DashboardSummary({
|
||||
required this.totalProducts,
|
||||
required this.inStock,
|
||||
required this.lowStock,
|
||||
required this.outOfStock,
|
||||
required this.draftProducts,
|
||||
required this.totalOrders,
|
||||
required this.pendingOrders,
|
||||
required this.activeOrders,
|
||||
required this.deliveredRevenue,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DashboardSummary &&
|
||||
totalProducts == other.totalProducts &&
|
||||
inStock == other.inStock &&
|
||||
lowStock == other.lowStock &&
|
||||
outOfStock == other.outOfStock &&
|
||||
draftProducts == other.draftProducts &&
|
||||
totalOrders == other.totalOrders &&
|
||||
pendingOrders == other.pendingOrders &&
|
||||
activeOrders == other.activeOrders &&
|
||||
deliveredRevenue == other.deliveredRevenue;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
totalProducts,
|
||||
inStock,
|
||||
lowStock,
|
||||
outOfStock,
|
||||
draftProducts,
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
activeOrders,
|
||||
deliveredRevenue,
|
||||
);
|
||||
|
||||
/// An empty summary used as the initial / default state.
|
||||
static const empty = DashboardSummary(
|
||||
totalProducts: 0,
|
||||
inStock: 0,
|
||||
lowStock: 0,
|
||||
outOfStock: 0,
|
||||
draftProducts: 0,
|
||||
totalOrders: 0,
|
||||
pendingOrders: 0,
|
||||
activeOrders: 0,
|
||||
deliveredRevenue: 0,
|
||||
);
|
||||
|
||||
/// Computes a [DashboardSummary] from raw repository data.
|
||||
factory DashboardSummary.fromData({
|
||||
required List<InventoryItem> inventoryItems,
|
||||
required List<ProductDraft> productDrafts,
|
||||
required List<Order> orders,
|
||||
}) {
|
||||
// Inventory counts
|
||||
final totalProducts = inventoryItems.length;
|
||||
var inStock = 0;
|
||||
var lowStock = 0;
|
||||
var outOfStock = 0;
|
||||
for (final item in inventoryItems) {
|
||||
switch (item.status) {
|
||||
case InventoryStatus.inStock:
|
||||
inStock++;
|
||||
case InventoryStatus.lowStock:
|
||||
lowStock++;
|
||||
case InventoryStatus.outOfStock:
|
||||
outOfStock++;
|
||||
case InventoryStatus.draft:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Draft product count
|
||||
final draftProducts = productDrafts.where((d) => d.status == PublishStatus.draft).length;
|
||||
|
||||
// Order counts
|
||||
final totalOrders = orders.length;
|
||||
var pendingOrders = 0;
|
||||
var activeOrders = 0;
|
||||
var deliveredRevenue = 0.0;
|
||||
for (final order in orders) {
|
||||
switch (order.status) {
|
||||
case OrderStatus.pending:
|
||||
pendingOrders++;
|
||||
case OrderStatus.processing:
|
||||
case OrderStatus.shipped:
|
||||
activeOrders++;
|
||||
case OrderStatus.delivered:
|
||||
deliveredRevenue += order.total;
|
||||
case OrderStatus.cancelled:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return DashboardSummary(
|
||||
totalProducts: totalProducts,
|
||||
inStock: inStock,
|
||||
lowStock: lowStock,
|
||||
outOfStock: outOfStock,
|
||||
draftProducts: draftProducts,
|
||||
totalOrders: totalOrders,
|
||||
pendingOrders: pendingOrders,
|
||||
activeOrders: activeOrders,
|
||||
deliveredRevenue: deliveredRevenue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +1,21 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'composition/mobile_app_services.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
final config = KcAppConfig.fromEnvironment();
|
||||
final (:services, config: effectiveConfig) = KcBootstrap.run(
|
||||
config,
|
||||
MobileAppServices.serviceFactory,
|
||||
);
|
||||
|
||||
runApp(
|
||||
KcAppScope<MobileAppServices>(
|
||||
services: services,
|
||||
config: effectiveConfig,
|
||||
child: const KellMobileApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../dashboard/application/dashboard_controller.dart';
|
||||
import '../dashboard/domain/dashboard_summary.dart';
|
||||
|
||||
/// A mobile-optimized dashboard page showing aggregated summary data.
|
||||
///
|
||||
/// Uses a single-column scrollable layout suitable for smaller screens.
|
||||
class MobileDashboardPage extends StatefulWidget {
|
||||
final DashboardController controller;
|
||||
|
||||
const MobileDashboardPage({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<MobileDashboardPage> createState() => _MobileDashboardPageState();
|
||||
}
|
||||
|
||||
class _MobileDashboardPageState extends State<MobileDashboardPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
widget.controller.load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MobileDashboardPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onControllerChanged);
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
widget.controller.load();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
|
||||
if (controller.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (controller.error != null) {
|
||||
return Center(
|
||||
child: Text('Failed to load dashboard data.', style: Theme.of(context).textTheme.bodyLarge),
|
||||
);
|
||||
}
|
||||
|
||||
final summary = controller.summary;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(KcSpacing.md),
|
||||
children: [
|
||||
const KcSectionHeader(title: 'Overview'),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
_buildSummaryGrid(context, summary),
|
||||
const SizedBox(height: KcSpacing.xl),
|
||||
const KcSectionHeader(title: 'Recent Activity'),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
const KcEmptyState(
|
||||
icon: Icons.history,
|
||||
message:
|
||||
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) {
|
||||
final cards = [
|
||||
KcSummaryCard(
|
||||
icon: Icons.inventory_2,
|
||||
iconColor: KcColors.denimBlue,
|
||||
label: 'Total Products',
|
||||
value: '${summary.totalProducts}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.check_circle_outline,
|
||||
iconColor: KcColors.success,
|
||||
label: 'In Stock',
|
||||
value: '${summary.inStock}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
iconColor: KcColors.warning,
|
||||
label: 'Low Stock',
|
||||
value: '${summary.lowStock}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.edit_note,
|
||||
iconColor: KcColors.neutral,
|
||||
label: 'Draft',
|
||||
value: '${summary.draftProducts}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.receipt_long,
|
||||
iconColor: KcColors.denimBlue,
|
||||
label: 'Total Orders',
|
||||
value: '${summary.totalOrders}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.hourglass_empty,
|
||||
iconColor: KcColors.warning,
|
||||
label: 'Pending Orders',
|
||||
value: '${summary.pendingOrders}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.local_shipping_outlined,
|
||||
iconColor: KcColors.success,
|
||||
label: 'Active Orders',
|
||||
value: '${summary.activeOrders}',
|
||||
),
|
||||
KcSummaryCard(
|
||||
icon: Icons.attach_money,
|
||||
iconColor: KcColors.success,
|
||||
label: 'Revenue',
|
||||
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: KcSpacing.sm,
|
||||
mainAxisSpacing: KcSpacing.sm,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
childAspectRatio: 1.6,
|
||||
children: cards,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class FinancePlaceholderPage extends StatelessWidget {
|
||||
const FinancePlaceholderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('Finance page coming soon'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class IntegrationsPlaceholderPage extends StatelessWidget {
|
||||
const IntegrationsPlaceholderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('Integrations page coming soon'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Full-screen product detail page for mobile.
|
||||
///
|
||||
/// Receives the shared [ProductPublishingController] so that edits
|
||||
/// (name, price, description, category, status) are immediately
|
||||
/// reflected in the list when the user pops back.
|
||||
///
|
||||
/// Wraps the shared [ProductPreviewPanel] from [feature_wordpress]
|
||||
/// inside a [Scaffold] with an [AppBar] showing the product name.
|
||||
///
|
||||
/// Unlike the web layout where feedback SnackBars are handled by the
|
||||
/// publishing page wrapper, this detail page is pushed via [Navigator]
|
||||
/// and owns its own [Scaffold]. It therefore attaches its own listener
|
||||
/// to the controller and shows action-result SnackBars using its local
|
||||
/// [BuildContext], ensuring they are visible on the active screen.
|
||||
///
|
||||
/// Status-change actions (publish / move-to-draft) present a
|
||||
/// confirmation dialog before executing, reducing accidental taps on
|
||||
/// touch screens.
|
||||
class MobileProductDetailPage extends StatefulWidget {
|
||||
final ProductPublishingController controller;
|
||||
|
||||
const MobileProductDetailPage({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<MobileProductDetailPage> createState() => _MobileProductDetailPageState();
|
||||
}
|
||||
|
||||
class _MobileProductDetailPageState extends State<MobileProductDetailPage> {
|
||||
ProductPublishingController get _controller => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onControllerChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Handles action result feedback via SnackBars on this detail page.
|
||||
///
|
||||
/// Because this page is pushed on top of the list page via [Navigator],
|
||||
/// the list page's listener cannot reliably display SnackBars (they
|
||||
/// would appear behind this route). This listener ensures feedback is
|
||||
/// always visible to the operator.
|
||||
void _onControllerChanged() {
|
||||
if (!mounted) return;
|
||||
|
||||
final result = _controller.lastActionResult;
|
||||
if (result != null) {
|
||||
_controller.consumeActionResult();
|
||||
showStatusActionSnackBar(context, result);
|
||||
if (result.success) HapticFeedback.mediumImpact();
|
||||
}
|
||||
|
||||
final priceResult = _controller.lastPriceResult;
|
||||
if (priceResult != null) {
|
||||
_controller.consumePriceResult();
|
||||
showPriceActionSnackBar(context, priceResult);
|
||||
if (priceResult.success) HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
final nameResult = _controller.lastNameResult;
|
||||
if (nameResult != null) {
|
||||
_controller.consumeNameResult();
|
||||
showNameActionSnackBar(context, nameResult);
|
||||
if (nameResult.success) HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
final descriptionResult = _controller.lastDescriptionResult;
|
||||
if (descriptionResult != null) {
|
||||
_controller.consumeDescriptionResult();
|
||||
showDescriptionActionSnackBar(context, descriptionResult);
|
||||
if (descriptionResult.success) HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
final categoryResult = _controller.lastCategoryResult;
|
||||
if (categoryResult != null) {
|
||||
_controller.consumeCategoryResult();
|
||||
showCategoryActionSnackBar(context, categoryResult);
|
||||
if (categoryResult.success) HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog before executing a status change.
|
||||
///
|
||||
/// Returns `true` if the user confirmed, `false` otherwise.
|
||||
Future<bool> _confirmStatusChange({
|
||||
required String productName,
|
||||
required String actionLabel,
|
||||
required String description,
|
||||
}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('$actionLabel?'),
|
||||
content: Text(description),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(actionLabel)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
/// Publishes the product after confirmation.
|
||||
Future<void> _handlePublish(String id, String name) async {
|
||||
final confirmed = await _confirmStatusChange(
|
||||
productName: name,
|
||||
actionLabel: 'Publish',
|
||||
description: 'Publish "$name" to the store? This will make it visible to customers.',
|
||||
);
|
||||
if (confirmed && mounted) {
|
||||
_controller.updateStatus(id, PublishStatus.published);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the product to draft after confirmation.
|
||||
Future<void> _handleMoveToDraft(String id, String name) async {
|
||||
final confirmed = await _confirmStatusChange(
|
||||
productName: name,
|
||||
actionLabel: 'Move to Draft',
|
||||
description: 'Move "$name" back to draft? This will remove it from the store.',
|
||||
);
|
||||
if (confirmed && mounted) {
|
||||
_controller.updateStatus(id, PublishStatus.draft);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
final draft = _controller.selectedDraft;
|
||||
|
||||
// If the product was removed or deselected, pop back.
|
||||
if (draft == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
});
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(draft.name)),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ProductPreviewPanel(
|
||||
draft: draft,
|
||||
isUpdating: _controller.isUpdating(draft.id),
|
||||
onPublish: () => _handlePublish(draft.id, draft.name),
|
||||
onMoveToDraft: () => _handleMoveToDraft(draft.id, draft.name),
|
||||
onPriceChanged: (price) => _controller.updatePrice(draft.id, price),
|
||||
onNameChanged: (name) => _controller.updateName(draft.id, name),
|
||||
onDescriptionChanged: (desc) => _controller.updateDescription(draft.id, desc),
|
||||
onCategoryChanged: (cat) => _controller.updateCategory(draft.id, cat),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'mobile_product_detail_page.dart';
|
||||
|
||||
/// Mobile-optimized publishing workspace page.
|
||||
///
|
||||
/// Provides browsing, filtering, searching, sorting, and status changes
|
||||
/// for the product publishing workflow on smaller screens. Tapping a
|
||||
/// product pushes a full-screen detail page for viewing and editing.
|
||||
///
|
||||
/// Reuses the shared [ProductPublishingController] and all use cases
|
||||
/// from [feature_wordpress] — no business logic is forked for mobile.
|
||||
class MobilePublishingPage extends StatefulWidget {
|
||||
final ProductPublishingRepository repository;
|
||||
|
||||
const MobilePublishingPage({super.key, required this.repository});
|
||||
|
||||
@override
|
||||
State<MobilePublishingPage> createState() => _MobilePublishingPageState();
|
||||
}
|
||||
|
||||
class _MobilePublishingPageState extends State<MobilePublishingPage> {
|
||||
late final ProductPublishingController _controller;
|
||||
late final TextEditingController _searchController;
|
||||
|
||||
/// Whether the detail page is currently pushed on top.
|
||||
///
|
||||
/// When `true`, action-result SnackBars are suppressed here because the
|
||||
/// detail page owns its own listener and shows feedback in its own
|
||||
/// [Scaffold]. Without this guard, SnackBars would be rendered behind
|
||||
/// the detail route and be invisible to the operator.
|
||||
bool _detailPageActive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
|
||||
final repo = widget.repository;
|
||||
_controller = ProductPublishingController(
|
||||
GetProductDrafts(repo),
|
||||
PublishProduct(repo),
|
||||
UpdateProductStatus(repo),
|
||||
UpdateProductPrice(repo),
|
||||
UpdateProductName(repo),
|
||||
UpdateProductDescription(repo),
|
||||
UpdateProductCategory(repo),
|
||||
);
|
||||
|
||||
_controller.addListener(_onControllerChanged);
|
||||
_controller.load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onControllerChanged);
|
||||
_controller.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Handles action result feedback via SnackBars.
|
||||
///
|
||||
/// Suppressed while the detail page is active — the detail page has its
|
||||
/// own listener that shows SnackBars using its local context.
|
||||
void _onControllerChanged() {
|
||||
if (_detailPageActive) return;
|
||||
|
||||
final result = _controller.lastActionResult;
|
||||
if (result != null) {
|
||||
_controller.consumeActionResult();
|
||||
showStatusActionSnackBar(context, result);
|
||||
}
|
||||
|
||||
final priceResult = _controller.lastPriceResult;
|
||||
if (priceResult != null) {
|
||||
_controller.consumePriceResult();
|
||||
showPriceActionSnackBar(context, priceResult);
|
||||
}
|
||||
|
||||
final nameResult = _controller.lastNameResult;
|
||||
if (nameResult != null) {
|
||||
_controller.consumeNameResult();
|
||||
showNameActionSnackBar(context, nameResult);
|
||||
}
|
||||
|
||||
final descriptionResult = _controller.lastDescriptionResult;
|
||||
if (descriptionResult != null) {
|
||||
_controller.consumeDescriptionResult();
|
||||
showDescriptionActionSnackBar(context, descriptionResult);
|
||||
}
|
||||
|
||||
final categoryResult = _controller.lastCategoryResult;
|
||||
if (categoryResult != null) {
|
||||
_controller.consumeCategoryResult();
|
||||
showCategoryActionSnackBar(context, categoryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
if (_controller.isLoading) {
|
||||
return const KcLoadingState(message: 'Loading products…');
|
||||
}
|
||||
|
||||
if (_controller.error != null) {
|
||||
return KcErrorState(message: 'Failed to load product drafts.', onRetry: _controller.load);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
_buildFilterChips(),
|
||||
_buildSortAndCount(),
|
||||
Expanded(child: _buildProductList()),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Search bar with real-time filtering.
|
||||
Widget _buildSearchBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.sm, KcSpacing.md, KcSpacing.xs),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search products…',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_controller.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: KcSpacing.sm,
|
||||
vertical: KcSpacing.sm,
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onChanged: (query) {
|
||||
_controller.setSearchQuery(query);
|
||||
setState(() {}); // refresh clear button visibility
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Horizontal scrollable filter chips for status filtering.
|
||||
Widget _buildFilterChips() {
|
||||
const filters = [
|
||||
(null, 'All'),
|
||||
('draft', 'Draft'),
|
||||
('pendingReview', 'Pending'),
|
||||
('published', 'Published'),
|
||||
('unpublished', 'Unpublished'),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
|
||||
itemCount: filters.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: KcSpacing.xs),
|
||||
itemBuilder: (context, index) {
|
||||
final (value, label) = filters[index];
|
||||
final isActive = _controller.activeFilter == value;
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isActive,
|
||||
onSelected: (_) => _controller.setFilter(isActive ? null : value),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sort dropdown and product count row.
|
||||
Widget _buildSortAndCount() {
|
||||
final count = _controller.drafts.length;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$count ${count == 1 ? 'product' : 'products'}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<ProductSortField>(
|
||||
icon: const Icon(Icons.sort, size: 20),
|
||||
tooltip: 'Sort',
|
||||
onSelected: (field) {
|
||||
if (field == _controller.activeSortField) {
|
||||
_controller.setSort(field, ascending: !_controller.sortAscending);
|
||||
} else {
|
||||
_controller.setSort(field, ascending: true);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
_sortMenuItem(ProductSortField.name, 'Name'),
|
||||
_sortMenuItem(ProductSortField.lastModified, 'Last Modified'),
|
||||
_sortMenuItem(ProductSortField.status, 'Status'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuEntry<ProductSortField> _sortMenuItem(ProductSortField field, String label) {
|
||||
final isActive = _controller.activeSortField == field;
|
||||
return PopupMenuItem(
|
||||
value: field,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: isActive ? const TextStyle(fontWeight: FontWeight.bold) : null,
|
||||
),
|
||||
),
|
||||
if (isActive)
|
||||
Icon(_controller.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Scrollable product list using compact cards.
|
||||
Widget _buildProductList() {
|
||||
final drafts = _controller.drafts;
|
||||
|
||||
if (drafts.isEmpty) {
|
||||
return KcEmptyState(
|
||||
icon: Icons.search_off,
|
||||
message: _controller.searchQuery.isNotEmpty || _controller.activeFilter != null
|
||||
? 'No products match your criteria.'
|
||||
: 'No product drafts available.',
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _controller.load,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
|
||||
itemCount: drafts.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs),
|
||||
itemBuilder: (context, index) {
|
||||
final draft = drafts[index];
|
||||
return SizedBox(
|
||||
height: 72,
|
||||
child: ProductDraftCard(
|
||||
draft: draft,
|
||||
isSelected: draft.id == _controller.selectedDraft?.id,
|
||||
isCompact: true,
|
||||
isStale: _controller.isStale(draft),
|
||||
onTap: () => _navigateToDetail(draft),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigates to the full-screen product detail page.
|
||||
///
|
||||
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
|
||||
/// detail page is visible. Cleared when the detail page pops back.
|
||||
void _navigateToDetail(ProductDraft draft) {
|
||||
_controller.selectDraft(draft);
|
||||
setState(() => _detailPageActive = true);
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
MaterialPageRoute<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
|
||||
)
|
||||
.then((_) {
|
||||
if (mounted) setState(() => _detailPageActive = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../composition/mobile_app_services.dart';
|
||||
import '../dashboard/application/dashboard_controller.dart';
|
||||
import '../dashboard/application/get_dashboard_summary.dart';
|
||||
import '../pages/dashboard_page.dart';
|
||||
import '../pages/finance_placeholder_page.dart';
|
||||
import '../pages/integrations_placeholder_page.dart';
|
||||
import '../pages/mobile_publishing_page.dart';
|
||||
|
||||
/// The main shell for the mobile app.
|
||||
///
|
||||
/// Uses a [Scaffold] with a [NavigationBar] (Material 3 bottom navigation)
|
||||
/// to provide top-level section navigation. This is the mobile equivalent
|
||||
/// of `kell_web`'s [AppShell] which uses a [NavigationRail].
|
||||
///
|
||||
/// Unlike the web app which uses named routes, the mobile shell uses
|
||||
/// index-based tab switching with a stateful body to preserve tab state.
|
||||
class MobileShell extends StatefulWidget {
|
||||
const MobileShell({super.key});
|
||||
|
||||
@override
|
||||
State<MobileShell> createState() => _MobileShellState();
|
||||
}
|
||||
|
||||
class _MobileShellState extends State<MobileShell> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = KcAppScope.configOf<MobileAppServices>(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_titles[_selectedIndex]),
|
||||
actions: [
|
||||
_EnvironmentBadge(environment: config.environment),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
body: _buildBody(context),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() => _selectedIndex = index);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.inventory_2_outlined),
|
||||
selectedIcon: Icon(Icons.inventory_2),
|
||||
label: 'Inventory',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.sell_outlined),
|
||||
selectedIcon: Icon(Icons.sell),
|
||||
label: 'Products',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Orders',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.more_horiz),
|
||||
selectedIcon: Icon(Icons.more_horiz),
|
||||
label: 'More',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
final services = KcAppScope.of<MobileAppServices>(context);
|
||||
|
||||
switch (_selectedIndex) {
|
||||
case 0:
|
||||
return MobileDashboardPage(
|
||||
controller: DashboardController(
|
||||
GetDashboardSummary(
|
||||
inventoryRepository: services.inventoryRepository,
|
||||
productPublishingRepository: services.productPublishingRepository,
|
||||
ordersRepository: services.ordersRepository,
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return InventoryPage(
|
||||
repository: services.inventoryRepository,
|
||||
onViewProduct: (_) {
|
||||
// Cross-feature nav: switch to Products tab.
|
||||
setState(() => _selectedIndex = 2);
|
||||
},
|
||||
);
|
||||
case 2:
|
||||
return MobilePublishingPage(repository: services.productPublishingRepository);
|
||||
case 3:
|
||||
return OrdersPage(
|
||||
repository: services.ordersRepository,
|
||||
onViewProduct: (_) {
|
||||
setState(() => _selectedIndex = 2);
|
||||
},
|
||||
onViewInventory: (_) {
|
||||
setState(() => _selectedIndex = 1);
|
||||
},
|
||||
);
|
||||
case 4:
|
||||
return const _MorePage();
|
||||
default:
|
||||
return const Center(child: Text('Unknown section'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple "More" page that provides access to less frequently used sections.
|
||||
class _MorePage extends StatelessWidget {
|
||||
const _MorePage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.attach_money),
|
||||
title: const Text('Finance'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Finance')),
|
||||
body: const FinancePlaceholderPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.policy),
|
||||
title: const Text('Policy'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
final services = KcAppScope.of<MobileAppServices>(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Policy')),
|
||||
body: PolicyPage(
|
||||
repository: services.policyRepository,
|
||||
onViewRelatedPage: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.hub),
|
||||
title: const Text('Integrations'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Integrations')),
|
||||
body: const IntegrationsPlaceholderPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small coloured chip displayed in the [AppBar] that shows the current
|
||||
/// runtime environment (e.g. "FAKE" or "WP").
|
||||
class _EnvironmentBadge extends StatelessWidget {
|
||||
final KcAppEnvironment environment;
|
||||
|
||||
const _EnvironmentBadge({required this.environment});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
|
||||
switch (environment) {
|
||||
case KcAppEnvironment.fake:
|
||||
backgroundColor = Colors.orange.shade100;
|
||||
foregroundColor = Colors.orange.shade900;
|
||||
case KcAppEnvironment.wordpress:
|
||||
backgroundColor = Colors.green.shade100;
|
||||
foregroundColor = Colors.green.shade900;
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(
|
||||
environment.label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -49,6 +56,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
design_system:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/design_system"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -57,6 +71,34 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
feature_inventory:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/feature_inventory"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
feature_orders:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/feature_orders"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
feature_policy:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/feature_policy"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
feature_wordpress:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/feature_wordpress"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -75,6 +117,22 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -192,6 +250,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -208,6 +274,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.11.4 <4.0.0"
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
|
|
|||
|
|
@ -1,89 +1,36 @@
|
|||
name: kell_mobile
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
description: "Kell Creations mobile operations platform."
|
||||
publish_to: "none"
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
core:
|
||||
path: ../../packages/core
|
||||
design_system:
|
||||
path: ../../packages/design_system
|
||||
feature_inventory:
|
||||
path: ../../packages/feature_inventory
|
||||
feature_orders:
|
||||
path: ../../packages/feature_orders
|
||||
feature_policy:
|
||||
path: ../../packages/feature_policy
|
||||
feature_wordpress:
|
||||
path: ../../packages/feature_wordpress
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
|
|
|
|||
|
|
@ -1,30 +1,265 @@
|
|||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:core/core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/app.dart';
|
||||
import 'package:kell_mobile/composition/mobile_app_services.dart';
|
||||
|
||||
import 'package:kell_mobile/main.dart';
|
||||
Widget _buildTestApp() {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
);
|
||||
return KcAppScope<MobileAppServices>(
|
||||
services: MobileAppServices.fake(),
|
||||
config: config,
|
||||
child: const KellMobileApp(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('mobile shell loads with dashboard tab', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
expect(find.text('Dashboard'), findsWidgets);
|
||||
});
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
testWidgets('dashboard shows summary cards after loading', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('Overview'), findsOneWidget);
|
||||
expect(find.text('Total Products'), findsOneWidget);
|
||||
expect(find.text('In Stock'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('environment badge shows FAKE in fake mode', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('FAKE'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(5));
|
||||
});
|
||||
|
||||
testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the Inventory destination
|
||||
await tester.tap(find.text('Inventory').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The app bar title should change
|
||||
expect(find.text('Inventory'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('tapping More tab shows additional sections', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the More destination
|
||||
await tester.tap(find.text('More').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finance'), findsOneWidget);
|
||||
expect(find.text('Policy'), findsOneWidget);
|
||||
expect(find.text('Integrations'), findsOneWidget);
|
||||
});
|
||||
|
||||
// ── Mobile Publishing Surface tests ──────────────────────────────
|
||||
|
||||
testWidgets('Products tab shows mobile publishing page with search bar', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the Products destination
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show the search bar
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search products…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Products tab shows filter chips', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show filter chips for status categories
|
||||
expect(find.widgetWithText(FilterChip, 'All'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilterChip, 'Draft'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilterChip, 'Published'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Products tab shows product count', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fake data should produce a product count label
|
||||
expect(find.textContaining('products'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('Products tab shows sort button', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have a sort icon/button
|
||||
expect(find.byIcon(Icons.sort), findsOneWidget);
|
||||
});
|
||||
|
||||
// ── Stage 6A: Android feedback and action polish tests ────────────
|
||||
|
||||
testWidgets('tapping product card navigates to detail page', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to Products tab
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the first product card in the list
|
||||
final cards = find.byType(GestureDetector);
|
||||
expect(cards, findsWidgets);
|
||||
|
||||
// Tap on the first product in the list view
|
||||
final firstProduct = find.byWidgetPredicate(
|
||||
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
|
||||
);
|
||||
if (firstProduct.evaluate().isNotEmpty) {
|
||||
await tester.tap(firstProduct.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should now be on the detail page — has an AppBar with a back button
|
||||
expect(find.byType(AppBar), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('detail page shows confirmation dialog for publish action', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to Products tab
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the first 72-height card to navigate to detail
|
||||
final firstProduct = find.byWidgetPredicate(
|
||||
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
|
||||
);
|
||||
if (firstProduct.evaluate().isNotEmpty) {
|
||||
await tester.tap(firstProduct.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Scroll down to reveal the status action button
|
||||
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Look for Publish or Move to Draft button
|
||||
final publishButton = find.text('Publish to Store');
|
||||
final moveToDraftButton = find.text('Move to Draft');
|
||||
|
||||
if (publishButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(publishButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirmation dialog should appear
|
||||
expect(find.text('Publish?'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
expect(find.text('Publish'), findsOneWidget);
|
||||
|
||||
// Cancel should dismiss the dialog
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog should be gone
|
||||
expect(find.text('Publish?'), findsNothing);
|
||||
} else if (moveToDraftButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(moveToDraftButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirmation dialog should appear
|
||||
expect(find.text('Move to Draft?'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
|
||||
// Cancel should dismiss the dialog
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog should be gone
|
||||
expect(find.text('Move to Draft?'), findsNothing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('detail page shows product name in app bar', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to Products tab
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the first product card
|
||||
final firstProduct = find.byWidgetPredicate(
|
||||
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
|
||||
);
|
||||
if (firstProduct.evaluate().isNotEmpty) {
|
||||
await tester.tap(firstProduct.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Detail page AppBar should show the product name
|
||||
// The AppBar should exist and have a title
|
||||
expect(find.byType(AppBar), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('detail page back navigation returns to product list', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to Products tab
|
||||
await tester.tap(find.text('Products').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the first product card
|
||||
final firstProduct = find.byWidgetPredicate(
|
||||
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
|
||||
);
|
||||
if (firstProduct.evaluate().isNotEmpty) {
|
||||
await tester.tap(firstProduct.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Press back (the leading back button in AppBar)
|
||||
final backButton = find.byType(BackButton);
|
||||
if (backButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(backButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should be back on the product list with search bar
|
||||
expect(find.text('Search products…'), findsOneWidget);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
/// |------------------------|----------------------------------------------|----------|
|
||||
/// | `KC_ENV` | `fake` or `wordpress` | `fake` |
|
||||
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
|
||||
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
||||
/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) |
|
||||
///
|
||||
/// Usage:
|
||||
/// ```sh
|
||||
/// flutter run -d chrome \
|
||||
/// --dart-define=KC_ENV=wordpress \
|
||||
/// --dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \
|
||||
/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
||||
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
||||
/// ```
|
||||
class AppConfig {
|
||||
/// The environment mode: `fake` or `wordpress`.
|
||||
final AppEnvironment environment;
|
||||
/// The original class names are preserved as typedefs so that existing
|
||||
/// references continue to compile without changes.
|
||||
library;
|
||||
|
||||
/// WordPress / WooCommerce site URL (only used in [AppEnvironment.wordpress]).
|
||||
final String wcSiteUrl;
|
||||
export 'package:core/core.dart' show KcAppConfig, KcAppEnvironment;
|
||||
|
||||
/// WooCommerce REST API consumer key.
|
||||
final String wcConsumerKey;
|
||||
import 'package:core/core.dart';
|
||||
|
||||
/// WooCommerce REST API consumer secret.
|
||||
final String wcConsumerSecret;
|
||||
/// @Deprecated('Use KcAppConfig from core instead.')
|
||||
typedef AppConfig = KcAppConfig;
|
||||
|
||||
const AppConfig({
|
||||
required this.environment,
|
||||
required this.wcSiteUrl,
|
||||
required this.wcConsumerKey,
|
||||
required this.wcConsumerSecret,
|
||||
});
|
||||
|
||||
/// Reads configuration from compile-time `--dart-define` constants.
|
||||
factory AppConfig.fromEnvironment() {
|
||||
const envString = String.fromEnvironment('KC_ENV', defaultValue: 'fake');
|
||||
const siteUrl = String.fromEnvironment('KC_WC_SITE_URL');
|
||||
const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY');
|
||||
const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET');
|
||||
|
||||
final environment = AppEnvironment.fromString(envString);
|
||||
|
||||
return AppConfig(
|
||||
environment: environment,
|
||||
wcSiteUrl: siteUrl,
|
||||
wcConsumerKey: consumerKey,
|
||||
wcConsumerSecret: consumerSecret,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the WordPress configuration values are all present and non-empty.
|
||||
bool get hasWordPressConfig =>
|
||||
wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty;
|
||||
|
||||
/// A human-readable label for the current environment (e.g. for badges).
|
||||
String get environmentLabel => environment.label;
|
||||
}
|
||||
|
||||
/// The supported runtime environments.
|
||||
enum AppEnvironment {
|
||||
/// In-memory fakes – no network calls.
|
||||
fake('fake', 'FAKE'),
|
||||
|
||||
/// Real WooCommerce backend.
|
||||
wordpress('wordpress', 'WP');
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
|
||||
const AppEnvironment(this.key, this.label);
|
||||
|
||||
/// Parses a string into an [AppEnvironment], defaulting to [fake].
|
||||
static AppEnvironment fromString(String value) {
|
||||
for (final env in values) {
|
||||
if (env.key == value.toLowerCase()) return env;
|
||||
}
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
/// @Deprecated('Use KcAppEnvironment from core instead.')
|
||||
typedef AppEnvironment = KcAppEnvironment;
|
||||
|
|
|
|||
|
|
@ -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 'app_config.dart';
|
||||
import 'app_services.dart';
|
||||
|
||||
/// An [InheritedWidget] that exposes [AppServices] and [AppConfig] to the
|
||||
/// widget tree.
|
||||
///
|
||||
/// Wrap the app (or a subtree) with [AppScope] and retrieve the services
|
||||
/// anywhere below via [AppScope.of(context)].
|
||||
class AppScope extends InheritedWidget {
|
||||
final AppServices services;
|
||||
final AppConfig config;
|
||||
export 'package:core/core.dart' show KcAppScope;
|
||||
|
||||
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.
|
||||
///
|
||||
|
|
@ -23,16 +35,12 @@ class AppScope extends InheritedWidget {
|
|||
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.
|
||||
static AppConfig configOf(BuildContext context) {
|
||||
static KcAppConfig configOf(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'No AppScope found in the widget tree');
|
||||
return scope!.config;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(AppScope oldWidget) =>
|
||||
services != oldWidget.services || config != oldWidget.config;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
/// Holds the concrete service implementations used by 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
|
||||
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
||||
/// a real WooCommerce-backed product repository while keeping other services
|
||||
/// fake until their backends are ready.
|
||||
class AppServices {
|
||||
class AppServices extends KcAppServices {
|
||||
final InventoryRepository inventoryRepository;
|
||||
final OrdersRepository ordersRepository;
|
||||
final PolicyRepository policyRepository;
|
||||
|
|
@ -57,4 +62,16 @@ class AppServices {
|
|||
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';
|
||||
|
||||
/// 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.
|
||||
/// If any credential is missing the app falls back to fake mode and logs a
|
||||
/// warning so the developer knows what went wrong.
|
||||
/// Existing call sites:
|
||||
/// ```dart
|
||||
/// final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
||||
/// ```
|
||||
///
|
||||
/// New code should prefer:
|
||||
/// ```dart
|
||||
/// final (:services, config: effectiveConfig) = KcBootstrap.run(
|
||||
/// config,
|
||||
/// AppServices.serviceFactory,
|
||||
/// );
|
||||
/// ```
|
||||
class Bootstrap {
|
||||
const Bootstrap._();
|
||||
|
||||
|
|
@ -18,39 +36,7 @@ class Bootstrap {
|
|||
/// Returns a record containing the resolved services and the effective
|
||||
/// config (which may differ from the input when WordPress credentials are
|
||||
/// missing and a fallback to fake mode occurs).
|
||||
static ({AppServices services, AppConfig config}) run(AppConfig config) {
|
||||
switch (config.environment) {
|
||||
case AppEnvironment.fake:
|
||||
return (services: AppServices.fake(), config: config);
|
||||
|
||||
case AppEnvironment.wordpress:
|
||||
if (!config.hasWordPressConfig) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
debugPrint(
|
||||
'⚠️ KC_ENV=wordpress but WooCommerce credentials are missing.\n'
|
||||
' Falling back to fake mode.\n'
|
||||
' Set KC_WC_SITE_URL, KC_WC_CONSUMER_KEY, and '
|
||||
'KC_WC_CONSUMER_SECRET via --dart-define.',
|
||||
);
|
||||
}
|
||||
final fallbackConfig = AppConfig(
|
||||
environment: AppEnvironment.fake,
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
);
|
||||
return (services: AppServices.fake(), config: fallbackConfig);
|
||||
}
|
||||
|
||||
return (
|
||||
services: AppServices.wordpress(
|
||||
siteUrl: config.wcSiteUrl,
|
||||
consumerKey: config.wcConsumerKey,
|
||||
consumerSecret: config.wcConsumerSecret,
|
||||
),
|
||||
config: config,
|
||||
);
|
||||
}
|
||||
static ({AppServices services, KcAppConfig config}) run(KcAppConfig config) {
|
||||
return KcBootstrap.run<AppServices>(config, AppServices.serviceFactory);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import '../dashboard/application/dashboard_controller.dart';
|
|||
import '../dashboard/domain/dashboard_summary.dart';
|
||||
import '../navigation/app_navigation.dart';
|
||||
import '../routing/app_routes.dart';
|
||||
import '../shell/widgets/empty_state_panel.dart';
|
||||
import '../shell/widgets/section_header.dart';
|
||||
import '../shell/widgets/summary_card.dart';
|
||||
|
||||
/// The main dashboard page showing aggregated summary data.
|
||||
///
|
||||
|
|
@ -68,11 +65,11 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
|
||||
return ListView(
|
||||
children: [
|
||||
const SectionHeader(title: 'Overview'),
|
||||
const KcSectionHeader(title: 'Overview'),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
_buildSummaryGrid(context, summary),
|
||||
const SizedBox(height: KcSpacing.xl),
|
||||
SectionHeader(
|
||||
KcSectionHeader(
|
||||
title: 'Quick Actions',
|
||||
action: TextButton(
|
||||
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.inventory),
|
||||
|
|
@ -82,9 +79,9 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
const SizedBox(height: KcSpacing.sm),
|
||||
_buildQuickActions(context),
|
||||
const SizedBox(height: KcSpacing.xl),
|
||||
const SectionHeader(title: 'Recent Activity'),
|
||||
const KcSectionHeader(title: 'Recent Activity'),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
const EmptyStatePanel(
|
||||
const KcEmptyState(
|
||||
icon: Icons.history,
|
||||
message:
|
||||
'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 = [
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.inventory_2,
|
||||
iconColor: KcColors.denimBlue,
|
||||
label: 'Total Products',
|
||||
value: '${summary.totalProducts}',
|
||||
onTap: () => AppNavigation.dashboardToInventory(context),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.check_circle_outline,
|
||||
iconColor: KcColors.success,
|
||||
label: 'In Stock',
|
||||
value: '${summary.inStock}',
|
||||
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
iconColor: KcColors.warning,
|
||||
label: 'Low Stock',
|
||||
value: '${summary.lowStock}',
|
||||
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.edit_note,
|
||||
iconColor: KcColors.neutral,
|
||||
label: 'Draft',
|
||||
value: '${summary.draftProducts}',
|
||||
onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.receipt_long,
|
||||
iconColor: KcColors.denimBlue,
|
||||
label: 'Total Orders',
|
||||
value: '${summary.totalOrders}',
|
||||
onTap: () => AppNavigation.dashboardToOrders(context),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.hourglass_empty,
|
||||
iconColor: KcColors.warning,
|
||||
label: 'Pending Orders',
|
||||
value: '${summary.pendingOrders}',
|
||||
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.local_shipping_outlined,
|
||||
iconColor: KcColors.success,
|
||||
label: 'Active Orders',
|
||||
value: '${summary.activeOrders}',
|
||||
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'),
|
||||
),
|
||||
SummaryCard(
|
||||
KcSummaryCard(
|
||||
icon: Icons.attach_money,
|
||||
iconColor: KcColors.success,
|
||||
label: 'Revenue',
|
||||
|
|
|
|||
|
|
@ -1,38 +1,15 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A reusable empty-state panel shown when a section has no data yet.
|
||||
/// Re-exports [KcEmptyState] from the shared design system.
|
||||
///
|
||||
/// Displays an [icon], a [message], and an optional [action] widget
|
||||
/// (e.g. a button to create the first item).
|
||||
class EmptyStatePanel extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String message;
|
||||
final Widget? action;
|
||||
/// This file preserves backward compatibility for existing imports.
|
||||
/// New code should import directly from `package:design_system/design_system.dart`.
|
||||
///
|
||||
/// The original `EmptyStatePanel` class name is preserved as a typedef
|
||||
/// so that existing references continue to compile without changes.
|
||||
library;
|
||||
|
||||
const EmptyStatePanel({super.key, required this.icon, required this.message, this.action});
|
||||
export 'package:design_system/design_system.dart' show KcEmptyState;
|
||||
|
||||
@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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
/// @Deprecated('Use KcEmptyState from design_system instead.')
|
||||
typedef EmptyStatePanel = KcEmptyState;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,15 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A reusable section header used across app pages.
|
||||
/// Re-exports [KcSectionHeader] from the shared design system.
|
||||
///
|
||||
/// Displays a [title] with an optional trailing [action] widget
|
||||
/// (e.g. a "View all" button).
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? action;
|
||||
/// This file preserves backward compatibility for existing imports.
|
||||
/// New code should import directly from `package:design_system/design_system.dart`.
|
||||
///
|
||||
/// The original `SectionHeader` class name is preserved as a typedef
|
||||
/// so that existing references continue to compile without changes.
|
||||
library;
|
||||
|
||||
const SectionHeader({super.key, required this.title, this.action});
|
||||
export 'package:design_system/design_system.dart' show KcSectionHeader;
|
||||
|
||||
@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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
/// @Deprecated('Use KcSectionHeader from design_system instead.')
|
||||
typedef SectionHeader = KcSectionHeader;
|
||||
|
|
|
|||
|
|
@ -1,52 +1,15 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A stat / summary card that displays a [value] with a [label] and an [icon].
|
||||
/// Re-exports [KcSummaryCard] from the shared design system.
|
||||
///
|
||||
/// Used on the dashboard to show high-level KPIs such as total products,
|
||||
/// in-stock count, etc. Optionally tappable via [onTap] to navigate to a
|
||||
/// related page.
|
||||
class SummaryCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String label;
|
||||
final String value;
|
||||
/// This file preserves backward compatibility for existing imports.
|
||||
/// New code should import directly from `package:design_system/design_system.dart`.
|
||||
///
|
||||
/// The original `SummaryCard` class name is preserved as a typedef
|
||||
/// so that existing references continue to compile without changes.
|
||||
library;
|
||||
|
||||
/// Optional tap handler for cross-feature navigation from dashboard KPIs.
|
||||
final VoidCallback? onTap;
|
||||
export 'package:design_system/design_system.dart' show KcSummaryCard;
|
||||
|
||||
const SummaryCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.onTap,
|
||||
});
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
@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),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// @Deprecated('Use KcSummaryCard from design_system instead.')
|
||||
typedef SummaryCard = KcSummaryCard;
|
||||
|
|
|
|||
|
|
@ -283,5 +283,5 @@ packages:
|
|||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.11.4 <4.0.0"
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
/// Core shared abstractions for Kell Creations applications.
|
||||
///
|
||||
/// This package provides the platform-agnostic composition pattern used by
|
||||
/// all app targets (`kell_web`, `kell_mobile`, etc.):
|
||||
///
|
||||
/// - [KcAppConfig] / [KcAppEnvironment] — runtime configuration from `--dart-define`
|
||||
/// - [KcAppServices] — abstract service container base class
|
||||
/// - [KcServiceFactory] — generic factory for environment-aware service construction
|
||||
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
|
||||
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,297 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:core/core.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
group('KcAppConfig', () {
|
||||
test('creates config with required fields', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
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'});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
library;
|
||||
|
||||
// Theme
|
||||
export 'src/theme/kc_colors.dart';
|
||||
export 'src/theme/kc_spacing.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_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_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 'kc_colors.dart';
|
||||
import 'kc_typography.dart';
|
||||
|
||||
ThemeData buildKcTheme() {
|
||||
final base = ThemeData(useMaterial3: true);
|
||||
|
|
@ -18,30 +20,7 @@ ThemeData buildKcTheme() {
|
|||
elevation: 0,
|
||||
centerTitle: false,
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
color: KcColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
textTheme: base.textTheme.copyWith(
|
||||
headlineMedium: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: KcColors.deepTeal,
|
||||
),
|
||||
titleLarge: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KcColors.deepTeal,
|
||||
),
|
||||
bodyLarge: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: KcColors.deepTeal,
|
||||
),
|
||||
bodyMedium: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: KcColors.deepTeal,
|
||||
),
|
||||
),
|
||||
cardTheme: const CardThemeData(color: KcColors.surface, elevation: 0, margin: EdgeInsets.zero),
|
||||
textTheme: KcTypography.applyKcTypography(base.textTheme),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:design_system/design_system.dart';
|
||||
|
||||
void main() {
|
||||
// ── Existing widget tests ────────────────────────────────────────────────
|
||||
|
||||
group('KcStatusChip', () {
|
||||
testWidgets('renders label text', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -35,5 +37,388 @@ void main() {
|
|||
final theme = buildKcTheme();
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
{"version":2,"entries":[{"package":"design_system","rootUri":"../../design_system/","packageUri":"lib/"},{"package":"feature_orders","rootUri":"../","packageUri":"lib/"}]}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
{
|
||||
"configVersion": 2,
|
||||
"packages": [
|
||||
{
|
||||
"name": "async",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/async-2.13.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/boolean_selector-2.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/characters-1.4.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/clock-1.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/collection-1.19.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "design_system",
|
||||
"rootUri": "../../design_system",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.11"
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/fake_async-1.3.3",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"rootUri": "file:///D:/develop/flutter/packages/flutter",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.9"
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_lints-6.0.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"rootUri": "file:///D:/develop/flutter/packages/flutter_test",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.9"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker-11.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/lints-6.1.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.19",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/material_color_utilities-0.13.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.17.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/path-1.9.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"rootUri": "file:///D:/develop/flutter/bin/cache/pkg/sky_engine",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.9"
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/source_span-1.10.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/stack_trace-1.12.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/stream_channel-2.1.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/string_scanner-1.4.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/term_glyph-1.2.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.10",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.2.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vm_service-15.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "feature_orders",
|
||||
"rootUri": "../",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.11"
|
||||
}
|
||||
],
|
||||
"generator": "pub",
|
||||
"generatorVersion": "3.11.4",
|
||||
"flutterRoot": "file:///D:/develop/flutter",
|
||||
"flutterVersion": "3.41.6",
|
||||
"pubCache": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache"
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
{
|
||||
"roots": [
|
||||
"feature_orders"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "feature_orders",
|
||||
"version": "0.0.1",
|
||||
"dependencies": [
|
||||
"design_system",
|
||||
"flutter"
|
||||
],
|
||||
"devDependencies": [
|
||||
"flutter_lints",
|
||||
"flutter_test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"version": "6.0.0",
|
||||
"dependencies": [
|
||||
"lints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"fake_async",
|
||||
"flutter",
|
||||
"leak_tracker_flutter_testing",
|
||||
"matcher",
|
||||
"meta",
|
||||
"path",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"test_api",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "design_system",
|
||||
"version": "0.0.1",
|
||||
"dependencies": [
|
||||
"flutter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"characters",
|
||||
"collection",
|
||||
"material_color_utilities",
|
||||
"meta",
|
||||
"sky_engine",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"version": "6.1.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"version": "2.1.4",
|
||||
"dependencies": [
|
||||
"async"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"version": "1.17.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"version": "1.19.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"version": "3.0.10",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"leak_tracker",
|
||||
"leak_tracker_testing",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"version": "2.2.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"version": "1.12.1",
|
||||
"dependencies": [
|
||||
"path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"version": "1.1.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"version": "1.3.3",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"version": "1.9.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"version": "0.12.19",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"meta",
|
||||
"stack_trace",
|
||||
"term_glyph",
|
||||
"test_api"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"version": "0.7.10",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"boolean_selector",
|
||||
"collection",
|
||||
"meta",
|
||||
"source_span",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"string_scanner",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"version": "0.13.0",
|
||||
"dependencies": [
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"version": "1.4.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "async",
|
||||
"version": "2.13.1",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"version": "3.0.2",
|
||||
"dependencies": [
|
||||
"leak_tracker",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"version": "11.0.2",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"meta",
|
||||
"path",
|
||||
"vm_service"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"version": "1.2.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"version": "1.4.1",
|
||||
"dependencies": [
|
||||
"source_span"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"version": "1.10.2",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"path",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"version": "2.1.2",
|
||||
"dependencies": [
|
||||
"source_span",
|
||||
"string_scanner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"version": "15.0.2",
|
||||
"dependencies": []
|
||||
}
|
||||
],
|
||||
"configVersion": 1
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
3.41.6
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
design_system:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../design_system"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
sdks:
|
||||
dart: ">=3.11.4 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ export 'src/domain/product_publishing_repository.dart';
|
|||
export 'src/domain/publish_status.dart';
|
||||
|
||||
// Application
|
||||
export 'src/application/product_publishing_controller.dart' show ProductSortField;
|
||||
export 'src/application/get_product_drafts.dart';
|
||||
export 'src/application/product_publishing_controller.dart';
|
||||
export 'src/application/publish_product.dart';
|
||||
export 'src/application/update_product_category.dart';
|
||||
export 'src/application/update_product_description.dart';
|
||||
export 'src/application/update_product_name.dart';
|
||||
|
|
@ -21,3 +23,7 @@ export 'src/application/update_product_status.dart';
|
|||
|
||||
// Presentation
|
||||
export 'src/presentation/product_publishing_page.dart';
|
||||
export 'src/presentation/widgets/product_draft_card.dart';
|
||||
export 'src/presentation/widgets/product_preview_panel.dart';
|
||||
export 'src/presentation/widgets/publish_status_chip.dart';
|
||||
export 'src/presentation/widgets/status_action_snack_bar.dart';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ import 'update_product_name.dart';
|
|||
import 'update_product_price.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.
|
||||
enum ProductSortField {
|
||||
/// Sort alphabetically by product name.
|
||||
|
|
@ -105,6 +114,36 @@ class CategoryActionResult {
|
|||
});
|
||||
}
|
||||
|
||||
/// The outcome of a bulk status-change action (e.g. bulk move-to-draft).
|
||||
///
|
||||
/// Consumed once by the UI to show a SnackBar, then cleared.
|
||||
class BulkActionResult {
|
||||
/// The number of products that were successfully updated.
|
||||
final int successCount;
|
||||
|
||||
/// The number of products that failed to update.
|
||||
final int failureCount;
|
||||
|
||||
/// The target status that was applied.
|
||||
final PublishStatus targetStatus;
|
||||
|
||||
/// Names of products that failed, for operator visibility.
|
||||
final List<String> failedProductNames;
|
||||
|
||||
const BulkActionResult({
|
||||
required this.successCount,
|
||||
required this.failureCount,
|
||||
required this.targetStatus,
|
||||
this.failedProductNames = const [],
|
||||
});
|
||||
|
||||
/// The total number of products that were attempted.
|
||||
int get totalCount => successCount + failureCount;
|
||||
|
||||
/// Whether all attempted products were successfully updated.
|
||||
bool get allSucceeded => failureCount == 0;
|
||||
}
|
||||
|
||||
/// Controller that manages the product publishing workspace state, including
|
||||
/// filtering by publish status, free-text search, and draft selection.
|
||||
class ProductPublishingController extends ChangeNotifier {
|
||||
|
|
@ -157,6 +196,115 @@ class ProductPublishingController extends ChangeNotifier {
|
|||
/// Whether the current sort is ascending (`true`) or descending (`false`).
|
||||
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.
|
||||
///
|
||||
/// Used to prevent duplicate clicks and to let the UI show per-row
|
||||
|
|
@ -226,6 +374,68 @@ class ProductPublishingController extends ChangeNotifier {
|
|||
lastCategoryResult = null;
|
||||
}
|
||||
|
||||
/// The result of the last bulk status-change action.
|
||||
///
|
||||
/// Set after [bulkUpdateStatus] completes. The UI should read this once
|
||||
/// to show feedback (e.g. a SnackBar) and then call
|
||||
/// [consumeBulkActionResult] to clear it.
|
||||
BulkActionResult? lastBulkActionResult;
|
||||
|
||||
/// Clears [lastBulkActionResult] so the same result is not shown twice.
|
||||
void consumeBulkActionResult() {
|
||||
lastBulkActionResult = null;
|
||||
}
|
||||
|
||||
/// Updates the publishing status of all [multiSelectedIds] to [status].
|
||||
///
|
||||
/// Processes each selected product sequentially to respect API rate limits.
|
||||
/// Tracks per-row updating state via [updatingIds]. After all updates
|
||||
/// complete (whether successful or not), reloads once and clears the
|
||||
/// multi-selection.
|
||||
///
|
||||
/// Does nothing if no items are multi-selected.
|
||||
Future<void> bulkUpdateStatus(PublishStatus status) async {
|
||||
if (multiSelectedIds.isEmpty) return;
|
||||
|
||||
// Snapshot the ids to process — the set may be cleared during iteration.
|
||||
final idsToProcess = List<String>.of(multiSelectedIds);
|
||||
|
||||
// Mark all as updating.
|
||||
updatingIds.addAll(idsToProcess);
|
||||
_safeNotify();
|
||||
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
final failedNames = <String>[];
|
||||
|
||||
for (final id in idsToProcess) {
|
||||
if (_disposed) return;
|
||||
try {
|
||||
await _updateProductStatus(id, status);
|
||||
successCount++;
|
||||
} catch (_) {
|
||||
failureCount++;
|
||||
failedNames.add(_productNameById(id));
|
||||
}
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
|
||||
// Clean up updating state and multi-selection.
|
||||
updatingIds.removeAll(idsToProcess);
|
||||
multiSelectedIds.clear();
|
||||
|
||||
lastBulkActionResult = BulkActionResult(
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
targetStatus: status,
|
||||
failedProductNames: failedNames,
|
||||
);
|
||||
|
||||
// Single reload after all updates.
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Loads all product drafts and applies any current filter / search.
|
||||
Future<void> load() async {
|
||||
isLoading = true;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../application/get_product_drafts.dart';
|
||||
import '../application/product_publishing_controller.dart';
|
||||
|
|
@ -50,10 +51,13 @@ class ProductPublishingPage extends StatefulWidget {
|
|||
|
||||
class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||
late final ProductPublishingController controller;
|
||||
late final FocusNode _listFocusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listFocusNode = FocusNode();
|
||||
|
||||
final repo = widget.repository;
|
||||
controller = ProductPublishingController(
|
||||
GetProductDrafts(repo),
|
||||
|
|
@ -87,6 +91,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
void dispose() {
|
||||
controller.removeListener(_onControllerChanged);
|
||||
controller.dispose();
|
||||
_listFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +125,56 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
controller.consumeCategoryResult();
|
||||
showCategoryActionSnackBar(context, categoryResult);
|
||||
}
|
||||
|
||||
final bulkResult = controller.lastBulkActionResult;
|
||||
if (bulkResult != null) {
|
||||
controller.consumeBulkActionResult();
|
||||
showBulkActionSnackBar(context, bulkResult);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog before executing bulk move-to-draft.
|
||||
Future<void> _confirmBulkMoveToDraft(BuildContext context) async {
|
||||
final count = controller.multiSelectedCount;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Move to Draft'),
|
||||
content: Text('Move $count ${count == 1 ? 'product' : 'products'} to draft status?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Move to Draft'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -182,6 +237,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
|
||||
Widget _buildDraftList() {
|
||||
final count = controller.drafts.length;
|
||||
final isCompact = controller.listDensity == ListDensity.compact;
|
||||
|
||||
if (count == 0) {
|
||||
return Center(
|
||||
|
|
@ -201,34 +257,66 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||
child: Text(
|
||||
'$count ${count == 1 ? 'product' : 'products'}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: count,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final draft = controller.drafts[index];
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
child: ProductDraftCard(
|
||||
draft: draft,
|
||||
isSelected: draft.id == controller.selectedDraft?.id,
|
||||
onTap: () => controller.selectDraft(draft),
|
||||
return Focus(
|
||||
focusNode: _listFocusNode,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Multi-select action bar ──────────────────────────────────
|
||||
if (controller.isMultiSelectActive)
|
||||
_MultiSelectBar(
|
||||
selectedCount: controller.multiSelectedCount,
|
||||
totalCount: count,
|
||||
onSelectAll: controller.selectAllVisible,
|
||||
onClearSelection: controller.clearMultiSelection,
|
||||
onBulkMoveToDraft: () => _confirmBulkMoveToDraft(context),
|
||||
),
|
||||
// ── List header with count and density toggle ───────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$count ${count == 1 ? 'product' : 'products'}',
|
||||
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(
|
||||
child: ListView.separated(
|
||||
itemCount: count,
|
||||
separatorBuilder: (_, _) => SizedBox(height: isCompact ? KcSpacing.xs : KcSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final draft = controller.drafts[index];
|
||||
return SizedBox(
|
||||
height: isCompact ? 72 : 180,
|
||||
child: ProductDraftCard(
|
||||
draft: draft,
|
||||
isSelected: draft.id == controller.selectedDraft?.id,
|
||||
onTap: () => controller.selectDraft(draft),
|
||||
isMultiSelected: controller.isMultiSelected(draft.id),
|
||||
onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id),
|
||||
isCompact: isCompact,
|
||||
isStale: controller.isStale(draft),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -262,3 +350,59 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact action bar shown above the product list when multi-select is
|
||||
/// active. Displays the count of selected items and provides bulk actions,
|
||||
/// Select All, and Clear Selection.
|
||||
class _MultiSelectBar extends StatelessWidget {
|
||||
final int selectedCount;
|
||||
final int totalCount;
|
||||
final VoidCallback onSelectAll;
|
||||
final VoidCallback onClearSelection;
|
||||
final VoidCallback onBulkMoveToDraft;
|
||||
|
||||
const _MultiSelectBar({
|
||||
required this.selectedCount,
|
||||
required this.totalCount,
|
||||
required this.onSelectAll,
|
||||
required this.onClearSelection,
|
||||
required this.onBulkMoveToDraft,
|
||||
});
|
||||
|
||||
@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 SizedBox(width: KcSpacing.sm),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onBulkMoveToDraft,
|
||||
icon: const Icon(Icons.drafts_outlined, size: 16),
|
||||
label: const Text('Move to Draft'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')),
|
||||
TextButton(onPressed: onClearSelection, child: const Text('Clear')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,206 @@ import 'publish_status_chip.dart';
|
|||
/// A card displaying a summary of a [ProductDraft].
|
||||
///
|
||||
/// 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 {
|
||||
final ProductDraft draft;
|
||||
final bool isSelected;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final showCheckbox = isMultiSelected != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(KcSpacing.md),
|
||||
padding: EdgeInsets.all(isCompact ? KcSpacing.sm : KcSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: KcColors.surface,
|
||||
border: Border.all(
|
||||
color: isSelected ? KcColors.denimBlue : KcColors.border,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(isCompact ? 10 : 16),
|
||||
boxShadow: const [
|
||||
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
draft.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: KcSpacing.xs),
|
||||
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'\$${draft.price.toStringAsFixed(2)}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
Text(
|
||||
draft.category,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
PublishStatusChip(status: draft.status),
|
||||
Text(
|
||||
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
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,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
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),
|
||||
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
||||
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(
|
||||
children: [
|
||||
Text(
|
||||
'\$${draft.price.toStringAsFixed(2)}',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
Flexible(
|
||||
child: Text(
|
||||
draft.category,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: KcSpacing.xs),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
PublishStatusChip(status: draft.status),
|
||||
Text(
|
||||
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,16 +310,14 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.check, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _submitName,
|
||||
tooltip: 'Save name',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.xs),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _cancelNameEdit,
|
||||
tooltip: 'Cancel',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -336,8 +334,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: () => setState(() => _editingName = true),
|
||||
tooltip: 'Edit name',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
|
|
@ -370,8 +367,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _priceController,
|
||||
enabled: !widget.isUpdating,
|
||||
|
|
@ -392,16 +388,14 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.check, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _submitPrice,
|
||||
tooltip: 'Save price',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.xs),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _cancelEdit,
|
||||
tooltip: 'Cancel',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -435,8 +429,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: () => setState(() => _editingPrice = true),
|
||||
tooltip: 'Edit price',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -482,16 +475,14 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.check, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _submitCategory,
|
||||
tooltip: 'Save category',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
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(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -522,8 +513,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: () => setState(() => _editingCategory = true),
|
||||
tooltip: 'Edit category',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -547,16 +537,14 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.check, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _submitDescription,
|
||||
tooltip: 'Save description',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.xs),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: widget.isUpdating ? null : _cancelDescriptionEdit,
|
||||
tooltip: 'Cancel description edit',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -591,8 +579,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: () => setState(() => _editingDescription = true),
|
||||
tooltip: 'Edit description',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -192,6 +192,57 @@ void showCategoryActionSnackBar(BuildContext context, CategoryActionResult resul
|
|||
);
|
||||
}
|
||||
|
||||
/// Shows a [SnackBar] for the given [BulkActionResult].
|
||||
///
|
||||
/// Displays success count and, on partial failure, lists the names of
|
||||
/// products that failed to update.
|
||||
void showBulkActionSnackBar(BuildContext context, BulkActionResult result) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) return;
|
||||
|
||||
final String message;
|
||||
final Color backgroundColor;
|
||||
final IconData icon;
|
||||
|
||||
final verb = _pastVerbForStatus(result.targetStatus);
|
||||
|
||||
if (result.allSucceeded) {
|
||||
message =
|
||||
'${result.successCount} ${result.successCount == 1 ? 'product' : 'products'} $verb successfully.';
|
||||
backgroundColor = KcColors.success;
|
||||
icon = Icons.check_circle_outline;
|
||||
} else if (result.successCount == 0) {
|
||||
message =
|
||||
'Failed to ${_infinitiveVerbForStatus(result.targetStatus)} all ${result.failureCount} products.';
|
||||
backgroundColor = KcColors.danger;
|
||||
icon = Icons.error_outline;
|
||||
} else {
|
||||
final failedList = result.failedProductNames.take(3).join(', ');
|
||||
final extra = result.failedProductNames.length > 3
|
||||
? ' and ${result.failedProductNames.length - 3} more'
|
||||
: '';
|
||||
message = '${result.successCount} $verb, ${result.failureCount} failed ($failedList$extra).';
|
||||
backgroundColor = KcColors.warning;
|
||||
icon = Icons.warning_amber_outlined;
|
||||
}
|
||||
|
||||
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.allSucceeded ? const Duration(seconds: 3) : const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a [SnackBar] for the given [NameActionResult].
|
||||
void showNameActionSnackBar(BuildContext context, NameActionResult result) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ publish_to: "none"
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/application/get_product_drafts.dart';
|
||||
import 'package:feature_wordpress/src/application/product_publishing_controller.dart';
|
||||
import 'package:feature_wordpress/src/application/publish_product.dart';
|
||||
|
||||
void main() {
|
||||
late FakeProductPublishingRepository repository;
|
||||
|
|
@ -1017,6 +1014,583 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 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');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bulk status actions ──────────────────────────────────────────────
|
||||
|
||||
group('bulkUpdateStatus', () {
|
||||
test('does nothing when no items are multi-selected', () async {
|
||||
await controller.load();
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
// No result should be set.
|
||||
expect(controller.lastBulkActionResult, isNull);
|
||||
});
|
||||
|
||||
test('moves all selected products to draft', () async {
|
||||
await controller.load();
|
||||
|
||||
// Select two published products (ids 1 and 2).
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
expect(controller.multiSelectedCount, 2);
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
// Both should now be draft.
|
||||
final p1 = controller.drafts.firstWhere((d) => d.id == '1');
|
||||
final p2 = controller.drafts.firstWhere((d) => d.id == '2');
|
||||
expect(p1.status, PublishStatus.draft);
|
||||
expect(p2.status, PublishStatus.draft);
|
||||
});
|
||||
|
||||
test('sets lastBulkActionResult on all-success', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('3');
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
expect(controller.lastBulkActionResult, isNotNull);
|
||||
expect(controller.lastBulkActionResult!.successCount, 2);
|
||||
expect(controller.lastBulkActionResult!.failureCount, 0);
|
||||
expect(controller.lastBulkActionResult!.targetStatus, PublishStatus.draft);
|
||||
expect(controller.lastBulkActionResult!.allSucceeded, isTrue);
|
||||
expect(controller.lastBulkActionResult!.totalCount, 2);
|
||||
expect(controller.lastBulkActionResult!.failedProductNames, isEmpty);
|
||||
});
|
||||
|
||||
test('clears multi-selection after bulk action', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
controller.toggleMultiSelect('3');
|
||||
expect(controller.multiSelectedCount, 3);
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
expect(controller.multiSelectedIds, isEmpty);
|
||||
expect(controller.multiSelectedCount, 0);
|
||||
expect(controller.isMultiSelectActive, false);
|
||||
});
|
||||
|
||||
test('clears updatingIds after bulk action', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
expect(controller.updatingIds, isEmpty);
|
||||
});
|
||||
|
||||
test('handles mixed statuses — all become draft', () async {
|
||||
await controller.load();
|
||||
|
||||
// Select one of each status type.
|
||||
controller.toggleMultiSelect('1'); // published
|
||||
controller.toggleMultiSelect('3'); // pendingReview
|
||||
controller.toggleMultiSelect('4'); // draft (already)
|
||||
controller.toggleMultiSelect('6'); // unpublished
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
for (final id in ['1', '3', '4', '6']) {
|
||||
final product = controller.drafts.firstWhere((d) => d.id == id);
|
||||
expect(product.status, PublishStatus.draft, reason: 'Product $id should be draft');
|
||||
}
|
||||
expect(controller.lastBulkActionResult!.successCount, 4);
|
||||
});
|
||||
|
||||
test('consumeBulkActionResult clears the result', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
expect(controller.lastBulkActionResult, isNotNull);
|
||||
|
||||
controller.consumeBulkActionResult();
|
||||
expect(controller.lastBulkActionResult, isNull);
|
||||
});
|
||||
|
||||
test('lastBulkActionResult starts as null', () {
|
||||
expect(controller.lastBulkActionResult, isNull);
|
||||
});
|
||||
|
||||
test('preserves single-item preview selection after bulk action', () async {
|
||||
await controller.load();
|
||||
|
||||
// Select product 5 for preview.
|
||||
controller.selectBySku('SH-SUN-005');
|
||||
expect(controller.selectedDraft!.id, '5');
|
||||
|
||||
// Multi-select other products.
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
// Preview selection should still be on product 5.
|
||||
expect(controller.selectedDraft, isNotNull);
|
||||
expect(controller.selectedDraft!.id, '5');
|
||||
});
|
||||
|
||||
test('filter and sort persist after bulk action', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.setSort(ProductSortField.lastModified, ascending: false);
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
|
||||
await controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
|
||||
expect(controller.activeSortField, ProductSortField.lastModified);
|
||||
expect(controller.sortAscending, false);
|
||||
});
|
||||
|
||||
test('does not notify after disposal during bulk action', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
|
||||
final future = controller.bulkUpdateStatus(PublishStatus.draft);
|
||||
controller.dispose();
|
||||
|
||||
// Should complete without throwing.
|
||||
await future;
|
||||
});
|
||||
});
|
||||
|
||||
group('disposed guard', () {
|
||||
test('load does not notify after disposal', () async {
|
||||
// Start load, then immediately dispose.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/presentation/widgets/product_draft_card.dart';
|
||||
|
||||
void main() {
|
||||
final sampleDraft = ProductDraft(
|
||||
|
|
@ -18,17 +17,25 @@ void main() {
|
|||
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(
|
||||
theme: buildKcTheme(),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 200,
|
||||
width: 400,
|
||||
height: isCompact ? 72 : 200,
|
||||
width: isCompact ? 600 : 400,
|
||||
child: ProductDraftCard(
|
||||
draft: draft ?? sampleDraft,
|
||||
isSelected: isSelected,
|
||||
onTap: onTap,
|
||||
isCompact: isCompact,
|
||||
isStale: isStale,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -78,5 +85,62 @@ void main() {
|
|||
await tester.tap(find.text('Test Bowl Cozy'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/presentation/widgets/product_preview_panel.dart';
|
||||
|
||||
void main() {
|
||||
final sampleDraft = ProductDraft(
|
||||
|
|
@ -709,6 +708,68 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Touch target sizing (Stage 6B) ──────────────────────────────────
|
||||
|
||||
group('touch target sizing', () {
|
||||
// Material Design minimum touch target is 48×48 dp.
|
||||
// We verify the rendered size of each IconButton meets this.
|
||||
const minTarget = 48.0;
|
||||
|
||||
Finder iconButtonAncestor(String tooltip) =>
|
||||
find.ancestor(of: find.byTooltip(tooltip), matching: find.byType(IconButton));
|
||||
|
||||
testWidgets('edit name button has adequate touch target', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||
final size = tester.getSize(iconButtonAncestor('Edit name'));
|
||||
expect(size.width, greaterThanOrEqualTo(minTarget));
|
||||
expect(size.height, greaterThanOrEqualTo(minTarget));
|
||||
});
|
||||
|
||||
testWidgets('edit price button has adequate touch target', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||
final size = tester.getSize(iconButtonAncestor('Edit price'));
|
||||
expect(size.width, greaterThanOrEqualTo(minTarget));
|
||||
expect(size.height, greaterThanOrEqualTo(minTarget));
|
||||
});
|
||||
|
||||
testWidgets('edit category button has adequate touch target', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
|
||||
final size = tester.getSize(iconButtonAncestor('Edit category'));
|
||||
expect(size.width, greaterThanOrEqualTo(minTarget));
|
||||
expect(size.height, greaterThanOrEqualTo(minTarget));
|
||||
});
|
||||
|
||||
testWidgets('edit description button has adequate touch target', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
|
||||
final size = tester.getSize(iconButtonAncestor('Edit description'));
|
||||
expect(size.width, greaterThanOrEqualTo(minTarget));
|
||||
expect(size.height, greaterThanOrEqualTo(minTarget));
|
||||
});
|
||||
|
||||
testWidgets('save/cancel name buttons have adequate touch targets', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||
await tester.tap(find.byTooltip('Edit name'));
|
||||
await tester.pump();
|
||||
|
||||
final saveSize = tester.getSize(iconButtonAncestor('Save name'));
|
||||
final cancelSize = tester.getSize(iconButtonAncestor('Cancel'));
|
||||
expect(saveSize.width, greaterThanOrEqualTo(minTarget));
|
||||
expect(saveSize.height, greaterThanOrEqualTo(minTarget));
|
||||
expect(cancelSize.width, greaterThanOrEqualTo(minTarget));
|
||||
expect(cancelSize.height, greaterThanOrEqualTo(minTarget));
|
||||
});
|
||||
|
||||
testWidgets('price edit row uses flexible width for narrow screens', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||
await tester.tap(find.byIcon(Icons.edit));
|
||||
await tester.pump();
|
||||
|
||||
// The TextField should be wrapped in Expanded, not a fixed-width SizedBox.
|
||||
// Verify the TextField exists and the edit row renders without overflow.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Disabled state during isUpdating (Stage 2B) ─────────────────────
|
||||
|
||||
group('edit fields disabled during isUpdating', () {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/presentation/widgets/publish_status_chip.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildTestWidget(PublishStatus status) {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/application/product_publishing_controller.dart';
|
||||
import 'package:feature_wordpress/src/presentation/widgets/status_action_snack_bar.dart';
|
||||
|
||||
void main() {
|
||||
group('showStatusActionSnackBar', () {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version: 0.0.1
|
|||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -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 Overview: operations/index.md
|
||||
- CI/CD Workflow: operations/cicd-workflow.md
|
||||
- Architecture Workflow: operations/architecture-workflow.md
|
||||
- Standards:
|
||||
- Standards Overview: "standards/index.md"
|
||||
- Integrations:
|
||||
|
|
|
|||
Loading…
Reference in New Issue