Compare commits

...

31 Commits

Author SHA1 Message Date
Mike Kell b61c886c23 chore: remove 8 unnecessary_import directives and 1 unused variable
Validate Docs / validate-docs (push) Successful in 49s Details
Publish Docs / publish-docs (push) Successful in 1m28s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details
- feature_wordpress/test: removed 8 unnecessary_import directives across
  5 test files (barrel exports already cover these symbols)
- kell_mobile/test: removed unused productCards local variable
- dart analyze --fatal-infos now passes clean across all packages
- All 410 tests still pass (311 feature_wordpress, 14 kell_mobile,
  24 kell_web, 41 design_system, 20 core)
- Updated build_execution_tracker.md (Stage 7A → merged)
- Updated master_development_brief.md (next branch, validation state)
2026-05-30 10:53:00 -04:00
Mike Kell 2c3ed3b926 Merge feat/bulk-status-actions: Stage 7A bulk move-to-draft action
Publish Docs / publish-docs (push) Successful in 1m38s Details
2026-05-30 10:36:13 -04:00
Mike Kell ffc643739c feat: add bulk move-to-draft action (Stage 7A)
Add bulk status-change capability to the product publishing workspace,
starting with 'Move to Draft' as the first controlled bulk action.

Controller:
- Add BulkActionResult value class (successCount, failureCount,
  targetStatus, failedProductNames, totalCount, allSucceeded)
- Add bulkUpdateStatus() method: processes selected products
  sequentially with per-row updating state, single reload after
  completion, auto-clears multi-selection
- Add lastBulkActionResult / consumeBulkActionResult() pattern

Presentation:
- Add showBulkActionSnackBar() with three variants: all-success
  (green), total-failure (red), partial-failure (amber with up to
  3 failed product names)
- Update _MultiSelectBar with 'Move to Draft' OutlinedButton.icon
  and onBulkMoveToDraft callback
- Add _confirmBulkMoveToDraft() confirmation dialog
- Wire bulk result listener into _onControllerChanged

Tests:
- 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
- Total: 311/311 feature_wordpress, 24/24 kell_web

Tracking:
- Update master_development_brief.md: Stage 7A marked complete,
  entry criteria marked as all met, test count updated to 311
- Update build_execution_tracker.md: new slice entry added
2026-05-30 10:36:00 -04:00
Mike Kell 8aec65b46b Merge feat/android-mobile-ux-hardening into main (Stage 6B complete)
Publish Docs / publish-docs (push) Successful in 1m38s Details
Stage 6B: Android mobile workflow hardening

- Restore Material Design 48x48dp minimum touch targets on 12 IconButtons
  in ProductPreviewPanel by removing constraints/padding overrides
- Replace fixed-width SizedBox(width:80) on price edit TextField with
  Expanded for flexible layout on narrow mobile screens
- Add tooltip 'Edit price' for consistency with other edit buttons
- Wrap ProductPreviewPanel in SafeArea in MobileProductDetailPage to
  prevent content clipping under notches/gesture bars
- Add 6 new touch target rendered-size tests verifying >= 48x48dp
- Add .gitignore for feature_orders, untrack .dart_tool and pubspec.lock

Tests: 300/300 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web
2026-05-30 10:10:09 -04:00
Mike Kell fba7cba835 chore(feature_orders): add .gitignore, untrack .dart_tool and pubspec.lock
- Add standard Flutter library .gitignore to feature_orders package
  (was missing, unlike all other packages)
- Remove .dart_tool/ directory and pubspec.lock from git tracking
  to match project conventions
2026-05-30 10:09:40 -04:00
Mike Kell 871ae8c48b feat(Stage 6B): Android mobile workflow hardening
- Restore Material Design 48x48dp minimum touch targets on 12 IconButtons
  in ProductPreviewPanel by removing constraints/padding overrides
- Replace fixed-width SizedBox(width:80) on price edit TextField with
  Expanded for flexible layout on narrow mobile screens
- Add tooltip 'Edit price' for consistency with other edit buttons
- Wrap ProductPreviewPanel in SafeArea in MobileProductDetailPage to
  prevent content clipping under notches/gesture bars
- Add 6 new touch target rendered-size tests verifying >= 48x48dp
- Update master_development_brief.md and build_execution_tracker.md

Tests: 300/300 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web,
       41/41 design_system — 379 total, all passing.
Stage 6 (Android operational maturity) complete.
2026-05-30 09:45:10 -04:00
Mike Kell 4b5c96c5ec docs: sync tracker and brief with Stage 6A merge to main — add kell_mobile validation state, update baseline commit, fix tracker date, update improvement #6 through Stage 6A
Publish Docs / publish-docs (push) Successful in 49s Details
2026-05-30 01:00:04 -04:00
Mike Kell e23d41b098 feat(mobile): Stage 6A — Android feedback and action polish
Validate Docs / validate-docs (push) Successful in 55s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details
- Convert MobileProductDetailPage to StatefulWidget with local controller
  listener for SnackBar feedback in detail page context
- Add confirmation dialogs for publish/move-to-draft status changes
- Add haptic feedback (mediumImpact for status, lightImpact for field edits)
  on successful actions
- Guard MobilePublishingPage SnackBars with _detailPageActive flag to prevent
  invisible behind-route feedback when detail page is pushed
- Add 4 new Stage 6A widget tests (14 total kell_mobile tests passing)
- Update build_execution_tracker.md and master_development_brief.md
2026-05-29 19:13:46 -04:00
Mike Kell 591de0c5c4 feat(mobile): add Android publishing surface (Stage 5B)
Validate Docs / validate-docs (push) Successful in 2m12s Details
Publish Docs / publish-docs (push) Successful in 59s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details
- Add MobilePublishingPage with search, filter chips, sort, product count,
  compact card list, pull-to-refresh, and push navigation to detail
- Add MobileProductDetailPage wrapping shared ProductPreviewPanel with
  all narrow edit callbacks (status, price, name, description, category)
- Switch Products tab in MobileShell from ProductPublishingPage to
  MobilePublishingPage
- Expand feature_wordpress barrel exports for mobile consumption
- Add 4 new widget tests (10 total kell_mobile tests passing)
- Zero business logic forked — all shared layers reused
- dart analyze clean, all tests passing

Stage 5 (Android application foundation) complete.
2026-05-29 02:32:28 -04:00
Mike Kell 65466ba513 feat(mobile): Stage 5A — Android app shell and bootstrap
Validate Docs / validate-docs (push) Successful in 3m31s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details
Replace default Flutter counter template in kell_mobile with a fully
integrated mobile operations platform shell reusing shared packages.

Mobile app shell:
- MobileAppServices extending KcAppServices with fake()/wp() factories
- KellMobileApp with KcAppScope<MobileAppServices>, KcTheme, env badge
- MobileShell with 5-tab NavigationBar (Dashboard, Inventory, Orders,
  Publishing, More) using IndexedStack for state preservation
- KcBootstrap entry point with --dart-define environment variables

Dashboard:
- DashboardSummary value object with fromData()/empty() constructors
- GetDashboardSummary use case aggregating inventory, orders, publishing
- DashboardController (ChangeNotifier) with loading/error/summary state
- MobileDashboardPage with GridView summary cards using design system
  widgets (KcSectionHeader, KcSummaryCard, KcEmptyState)

Placeholder pages:
- FinancePlaceholderPage, IntegrationsPlaceholderPage for More tab
- Feature tab pages delegate to shared feature presentation layers

Infrastructure:
- pubspec.yaml references all shared packages (core, design_system,
  feature_inventory, feature_orders, feature_policy, feature_wordpress)
- SDK constraint corrected from ^3.11.4 to ^3.11.0 across all 14
  pubspec.yaml files to match installed Dart SDK 3.11.3

Tests:
- 6 new kell_mobile widget tests: shell loading, summary cards,
  environment badge, navigation bar destinations, tab switching, More menu
- All existing tests remain passing (24/24 kell_web, 294/294
  feature_wordpress)

Documentation:
- master_development_brief.md: Stage 5A marked complete, next branch
  updated to feat/android-publishing-surface (Stage 5B), kell_mobile
  platform description updated
- build_execution_tracker.md: Stage 5A entry added with full file list
2026-05-28 19:10:14 -04:00
Mike Kell f056d5f0b5 docs: sync master development brief with completed stages and resolved recommendations
Publish Docs / publish-docs (push) Successful in 1m2s Details
2026-05-22 11:03:00 -04:00
Mike Kell effaadc84b docs: full project analysis with recommendations and fixes
Publish Docs / publish-docs (push) Successful in 1m9s Details
2026-05-22 10:51:53 -04:00
Mike Kell 2af9a8b6cb docs: update tracker for merged feat/test-coverage-visibility (Stage 4D)
Publish Docs / publish-docs (push) Successful in 1m5s Details
- Update current status to Stage 4D complete (Stage 4 complete)

- Add feat/test-coverage-visibility slice entry with coverage baseline

- Set next branch to feat/android-app-shell (Stage 5A)
2026-05-22 10:22:59 -04:00
Mike Kell 4f72bdb486 Merge feat/test-coverage-visibility into main (Stage 4D complete) 2026-05-22 10:22:16 -04:00
Mike Kell f30ad24d8a feat(ci): add test coverage visibility to CI pipeline (Stage 4D)
Enhance flutter-test.yml to run tests with --coverage and parse lcov.info

files, producing aggregate summary table with per-package line coverage.

Changes:

- flutter-test.yml: add --coverage flag, lcov.info parsing, coverage %

- collect_coverage.sh: new local coverage helper with summary table

- tools/README.md: document collect_coverage.sh script

- .gitignore: add coverage/ directories

- master_development_brief.md: mark Stage 4D complete, document baseline

  coverage table, update next branch to Stage 5A, resolve improvement #5

Baseline coverage (2026-05-22):

- core: 85.7%% (42/49 lines, 20 tests)

- design_system: 100.0%% (88/88 lines, 41 tests)

- feature_wordpress: 84.7%% (857/1012 lines, 294 tests)

- kell_web: 54.1%% (191/353 lines, 24 tests)

- Overall: 78.4%% (1178/1502 lines, 379 tests)

No minimum thresholds enforced — visibility first.
2026-05-22 10:22:05 -04:00
Mike Kell 71abe9df7f docs: update briefs for merged feat/flutter-cicd (Stage 4C)
Publish Docs / publish-docs (push) Successful in 1m16s Details
- master_development_brief.md: mark Stage 4C complete, update baseline commit,

  next recommended branch (Stage 4D), tools/ description, resolve improvement #9

- build_execution_tracker.md: update current status to Stage 4C, add missing

  feat/shared-composition-pattern and feat/flutter-cicd slice entries
2026-05-22 10:11:51 -04:00
Mike Kell 6a6323ef57 Merge feat/flutter-cicd into main (Stage 4C complete) 2026-05-22 10:09:49 -04:00
Mike Kell b00072474b feat(ci): add Flutter CI/CD pipeline for Forgejo Actions (Stage 4C)
Add Forgejo Actions workflows for automated Flutter validation on PRs:

- flutter-analyze.yml: runs dart analyze --fatal-infos on all 8 packages/apps

- flutter-test.yml: runs flutter test per package with pass/fail reporting

- Aggregate test result summary table in workflow output

- Workflows trigger on PRs to main and all non-main branch pushes

- Uses ghcr.io/cirruslabs/flutter:stable container image

Populate tools/ directory with CI helper scripts:

- run_all_tests.sh: local test runner with optional --analyze flag

- README.md: documents scripts and CI workflow inventory

Validated locally:

- dart analyze: all 8 packages/apps clean (no issues)

- core: 20/20 tests passed

- design_system: 41/41 tests passed

- feature_wordpress: 294/294 tests passed

- kell_web: 24/24 tests passed

- Total: 379/379 tests passed
2026-05-22 10:09:42 -04:00
Mike Kell 0a0abc2c3d Merge feat/shared-composition-pattern into main (Stage 4B complete)
Publish Docs / publish-docs (push) Successful in 58s Details
2026-05-22 09:57:59 -04:00
Mike Kell 9eafc68fec feat(core): extract shared composition pattern into core package (Stage 4B)
Validate Docs / validate-docs (push) Successful in 1m13s Details
Extract AppConfig/AppEnvironment, AppServices, Bootstrap, and AppScope into core package as KcAppConfig, KcAppServices, KcBootstrap, and KcAppScope generic abstractions.

New core composition types:

- KcAppConfig: runtime config from --dart-define (KC_ENV, WC credentials)

- KcAppEnvironment: enum for fake/wordpress environments

- KcAppServices: abstract base for app service containers

- KcServiceFactory<T>: generic factory for fake/wordpress service creation

- KcBootstrap: shared bootstrap with env switch and WP credential fallback

- KcAppScope<T>: InheritedWidget exposing typed services + config to tree

kell_web backward compatibility:

- AppConfig/AppEnvironment are now typedefs to Kc-prefixed types

- AppServices extends KcAppServices with concrete repositories

- AppScope extends KcAppScope<AppServices> with direct InheritedWidget lookup

- Bootstrap delegates to KcBootstrap.run with app-specific factory

Tests: 20 new core tests, all 379 tests passing (core 20, design_system 41, feature_wordpress 294, kell_web 24). dart analyze clean.
2026-05-22 09:57:51 -04:00
Mike Kell 6f10efc88d Merge feat/design-system-shared-widgets into main — Stage 4A complete
Publish Docs / publish-docs (push) Successful in 57s Details
2026-05-22 09:42:39 -04:00
Mike Kell 8facefdff1 feat(design-system): Stage 4A — design system expansion and shared widget migration
- Migrate EmptyStatePanel, SectionHeader, SummaryCard from kell_web into design_system as KcEmptyState, KcSectionHeader, KcSummaryCard

- Add KcTypography shared typography scale with full Material 3 text style hierarchy

- Add KcBreakpoints responsive layout breakpoint utilities (compact/medium/expanded/large)

- Add KcLoadingState and KcErrorState shared state widgets

- Update kc_theme.dart to use KcTypography.applyKcTypography()

- Update kell_web dashboard_page.dart to use design_system widgets directly

- Replace kell_web shell widget files with backward-compatible typedef re-exports

- Expand design_system tests from 3 to 41 (all passing)

- All existing tests passing: design_system 41/41, feature_wordpress 294/294, kell_web 24/24

- dart analyze clean across design_system and kell_web
2026-05-22 09:42:31 -04:00
Mike Kell bee610ca2c docs: add Stage 4 — Platform foundations and cross-platform readiness
Publish Docs / publish-docs (push) Successful in 1m9s Details
2026-05-22 09:26:39 -04:00
Mike Kell 02090cde6a docs: add suggested improvements to master development brief
Publish Docs / publish-docs (push) Successful in 1m3s Details
2026-05-22 09:17:08 -04:00
Mike Kell eaf3e70d30 docs: update master development brief — Stage 3B complete, Stage 3 done
Publish Docs / publish-docs (push) Successful in 1m10s Details
Mark Stage 3B (list efficiency improvements) as complete with 294 tests passing. Update baseline commit reference, test count, and next recommended branch to Stage 4A (Android app shell).
2026-05-22 08:53:10 -04:00
Mike Kell 738336d953 Merge feat/list-efficiency-improvements into main — Stage 3B complete 2026-05-22 08:51:57 -04:00
Mike Kell a0aea373c2 feat(wordpress): Stage 3B — list efficiency improvements
Add compact/standard view toggle (ListDensity enum) to controller and page, allowing users to switch between a dense single-row layout and the full metadata card view.

Add staleness detection: isStale() flags products not modified in 30+ days with a schedule icon; staleCount() returns the count of stale items in the current filtered view.

Add keyboard navigation: selectNextDraft/selectPreviousDraft with arrow-key wrapping support; page wires up Focus + onKeyEvent for arrow-down/arrow-up.

Update ProductDraftCard with compact layout variant (two-row dense metadata), stale indicator icon, and description snippet in standard mode. Wrap long text in Flexible widgets to prevent overflow.

Increase standard card height from 160px to 180px to accommodate the new description snippet row.

Export ListDensity and ProductSortField from barrel file.

Add 42 new focused tests covering listDensity toggle, staleness boundary conditions, staleCount with filters, keyboard navigation wrapping, and compact card rendering. All 294 tests pass, dart analyze clean.
2026-05-22 08:51:32 -04:00
Mike Kell f3fbbca06d Merge feat/multi-select-groundwork into main (Stage 3A complete)
Publish Docs / publish-docs (push) Successful in 1m26s Details
2026-05-22 08:33:32 -04:00
Mike Kell dfe7ae1811 feat: add multi-select groundwork to product publishing (Stage 3A)
Add read-only multi-selection state to the product publishing workspace, preparing for future bulk actions without introducing any bulk writes.

Controller (ProductPublishingController):

- Add _multiSelectedIds Set<String> for tracking multi-selected product IDs

- Add toggleMultiSelect(id) to add/remove individual IDs

- Add clearMultiSelection() to deselect all

- Add selectAllVisible() to select all currently visible (filtered/searched) drafts

- Add isMultiSelected(id), multiSelectedIds, multiSelectedCount, isMultiSelectActive getters

- Multi-selection is independent of single-item preview selection

- Multi-selection persists across load cycles and write operations

UI (ProductDraftCard):

- Add optional isMultiSelected/onMultiSelectToggle props

- Show leading Checkbox when multi-select mode is active

- Tapping checkbox toggles multi-select; tapping card body still fires single-item preview

UI (ProductPublishingPage):

- Add _MultiSelectBar widget above product list when multi-select is active

- Shows selected count, Select All button, and Clear button

- Replace deprecated withOpacity() calls with withValues(alpha:)

Tests:

- 15 new multi-select controller tests covering toggle, clear, select-all,

  filter/search interaction, independence from preview selection, persistence

  across loads and writes, and listener notifications

- Total: 262 feature_wordpress tests passing

Validation:

- dart analyze: clean (0 issues)

- flutter test: 262/262 passed

Changed files:

- lib/src/application/product_publishing_controller.dart

- lib/src/presentation/widgets/product_draft_card.dart

- lib/src/presentation/product_publishing_page.dart

- test/product_publishing_controller_test.dart

- docs/development/master_development_brief.md
2026-05-22 08:33:24 -04:00
Mike Kell 49a3702cec Updated build status
Publish Docs / publish-docs (push) Successful in 1m34s Details
2026-04-11 16:49:51 -04:00
mtkell b81016df28 Merge pull request 'feat/publishing-ux-hardening' (#6) from feat/publishing-ux-hardening into main
Publish Docs / publish-docs (push) Successful in 59s Details
Reviewed-on: #6
2026-04-11 20:42:21 +00:00
94 changed files with 7231 additions and 1420 deletions

View File

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

View File

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

3
.gitignore vendored
View File

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

6
.markdownlint.json Normal file
View File

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

View File

@ -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
![Inventory Components](../../images/components-inventory.svg)

View File

@ -14,7 +14,7 @@ The source for this diagram is maintained as architecture code in:
## Diagram
![Enterprise Shared Services](../../images/containers-enterprise-services.svg)
![Enterprise Data Architecture](../../images/enterprise-data-architecture.svg)
## Included Data Services and Stores

View File

@ -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
![Platform Containers](../../images/containers-platform.svg)

View File

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

View File

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

View File

@ -8,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
![System Landscape](../images/system-landscape.svg)

View File

@ -25,49 +25,49 @@ Use this page to answer questions such as:
## 1. Enterprise Architecture Traceability
| Enterprise View | File | Supports Domains |
|---|---|---|
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
| Platform Context | `docs/architecture/context/platform.md` | All domains |
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
| Enterprise Shared Services | `docs/architecture/containers/enterprise-services.md` | All domains |
| Enterprise Data Architecture | `docs/architecture/containers/enterprise-data-architecture.md` | Inventory, MRP, WordPress, Social Media, Financial Analysis, Policy Repository |
| Enterprise Identity & Access Architecture | `docs/architecture/containers/enterprise-identity-access-architecture.md` | All application and governance domains |
| Enterprise View | File | Supports Domains |
| --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| System Landscape | `docs/architecture/system-landscape.md` | All domains |
| Platform Context | `docs/architecture/context/platform.md` | All domains |
| Platform Containers | `docs/architecture/containers/platform-containers.md` | All domains |
| Enterprise Shared Services | `docs/architecture/containers/enterprise-services.md` | All domains |
| Enterprise Data Architecture | `docs/architecture/containers/enterprise-data-architecture.md` | Inventory, MRP, WordPress, Social Media, Financial Analysis, Policy Repository |
| Enterprise Identity & Access Architecture | `docs/architecture/containers/enterprise-identity-access-architecture.md` | All application and governance domains |
| Enterprise Integration & Orchestration Architecture | `docs/architecture/containers/enterprise-integration-orchestration-architecture.md` | WordPress, Social Media, MRP, Inventory, Financial Analysis, Policy Repository |
| Enterprise Audit, Logging & Compliance Architecture | `docs/architecture/containers/enterprise-audit-logging-compliance-architecture.md` | All application and governance domains |
| Deployment Architecture | `docs/architecture/deployment/production.md` | All domains |
| Enterprise Audit, Logging & Compliance Architecture | `docs/architecture/containers/enterprise-audit-logging-compliance-architecture.md` | All application and governance domains |
| Deployment Architecture | `docs/architecture/deployment/production.md` | All domains |
---
## 2. Application Component Traceability
| Application Domain | Component View | File | Related Enterprise Views |
|---|---|---|---|
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| Craft Manufacturing / MRP | Craft Manufacturing / MRP Components | `docs/architecture/components/mrp.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Social Media Management | Social Media Management Components | `docs/architecture/components/social-media-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Financial Analysis | Financial Analysis Components | `docs/architecture/components/financial-analysis.md` | Shared Services, Data, Identity & Access, Audit |
| Application Domain | Component View | File | Related Enterprise Views |
| ------------------------- | ------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------ |
| Inventory | Inventory Components | `docs/architecture/components/inventory.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| Craft Manufacturing / MRP | Craft Manufacturing / MRP Components | `docs/architecture/components/mrp.md` | Shared Services, Data, Identity & Access, Audit, Integration |
| WordPress Management | WordPress Management Components | `docs/architecture/components/wordpress-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Social Media Management | Social Media Management Components | `docs/architecture/components/social-media-management.md` | Shared Services, Data, Identity & Access, Integration, Audit |
| Financial Analysis | Financial Analysis Components | `docs/architecture/components/financial-analysis.md` | Shared Services, Data, Identity & Access, Audit |
---
## 3. Dynamic Workflow Traceability
| Workflow | File | Primary Domains | Related Component Views |
|---|---|---|---|
| Order to Fulfillment | `docs/architecture/dynamic/order-to-fulfillment.md` | WordPress, Inventory, MRP, Financial Analysis | Inventory, MRP, WordPress, Financial Analysis |
| Product Publishing Workflow | `docs/architecture/dynamic/product-publishing.md` | WordPress Management, Shared Data | WordPress Management |
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
| Inventory-to-Production Workflow | `docs/architecture/dynamic/inventory-to-production.md` | Inventory, MRP | Inventory, MRP |
| Policy Approval and Retirement Workflow | `docs/architecture/dynamic/policy-approval-retirement.md` | Governance, Policy Repository | Enterprise Audit, Identity & Access, Policy Repository |
| Workflow | File | Primary Domains | Related Component Views |
| --------------------------------------- | --------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------ |
| Order to Fulfillment | `docs/architecture/dynamic/order-to-fulfillment.md` | WordPress, Inventory, MRP, Financial Analysis | Inventory, MRP, WordPress, Financial Analysis |
| Product Publishing Workflow | `docs/architecture/dynamic/product-publishing.md` | WordPress Management, Shared Data | WordPress Management |
| Social Campaign Publishing Workflow | `docs/architecture/dynamic/social-campaign-publishing.md` | Social Media Management, Integration | Social Media Management |
| Inventory-to-Production Workflow | `docs/architecture/dynamic/inventory-to-production.md` | Inventory, MRP | Inventory, MRP |
| Policy Approval and Retirement Workflow | `docs/architecture/dynamic/policy-approval-retirement.md` | Governance, Policy Repository | Enterprise Audit, Identity & Access, Policy Repository |
---
## 4. Governance Document Traceability
| Governance Document | File | Governs Domains | Related Workflows / Views |
|---|---|---|---|
| Document Control Policy | `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
| Governance Document | File | Governs Domains | Related Workflows / Views |
| -------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
| Document Control Policy | `docs/policies/governance/KC-POL-GOV-001-document-control-policy.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
| Policy Review and Retirement Procedure | `docs/policies/governance/KC-PRO-GOV-001-policy-review-and-retirement-procedure.md` | Governance, Policy Repository | Policy Approval and Retirement Workflow, Audit & Compliance Architecture |
---
@ -75,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ^3.11.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage:
environment:
sdk: ^3.11.4
sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:

View File

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

View File

@ -1 +0,0 @@
{"version":2,"entries":[{"package":"design_system","rootUri":"../../design_system/","packageUri":"lib/"},{"package":"feature_orders","rootUri":"../","packageUri":"lib/"}]}

View File

@ -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"
}

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage:
environment:
sdk: ^3.11.4
sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage:
environment:
sdk: ^3.11.4
sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
),
],
],

View File

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

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage:
environment:
sdk: ^3.11.4
sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:

View File

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

View File

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

View File

@ -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', () {

View File

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

View File

@ -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', () {

View File

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

View File

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

View File

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

View File

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

View File

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