Merge feat/observability-error-logging: Stage 16A error logging, crash capture, and bug reporting (1475/1475 tests passing)

This commit is contained in:
Mike Kell 2026-08-29 10:48:28 -04:00
commit 6389acd2ae
28 changed files with 3104 additions and 52 deletions

View File

@ -1,3 +1,13 @@
<!--
MD011 (no-reversed-links) is disabled for this file.
This PRD uses `(descriptive text)[cite: n]` source-citation markers, which are
syntactically indistinguishable from a reversed Markdown link. They are not
links and must not be "corrected" — running `markdownlint --fix` on this file
will silently rewrite them into malformed links and destroy the citations.
-->
<!-- markdownlint-disable MD011 -->
# Product Requirement Document (PRD): Kell Creations Operations Platform # Product Requirement Document (PRD): Kell Creations Operations Platform
## 1. Executive Summary & Vision ## 1. Executive Summary & Vision
@ -52,6 +62,7 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3]. - **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3]. - **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
- **Observability & Support:** Structured logging (`KcLogger`) with five severities and a bounded in-memory evidence buffer; global uncaught-error capture (`KcErrorReporter`) across framework, platform, and zone channels; operator bug reporting (`KcBugReportService`) that attaches the recent log tail; mandatory credential redaction (`KcRedactor`) on all logged context so API tokens and secrets can never reach a log or a submitted report.
--- ---
@ -76,7 +87,12 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] | | **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] | | **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] | | **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
| **Stage 10B** | **Square Catalog & Inventory Sync** | 🔄 **Current Stage**[cite: 2, 3] | | **Stage 10B** | Square Catalog & Inventory Sync | ✅ Merged |
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] | | **Stage 10C** | Square Mobile POS & In-Person Payments | ✅ Merged |
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] | | **Stage 10E** | `SquareSyncController`, `square()`/`multi()` factories, ID fix | ✅ Merged |
| **Stage 10F** | Square Sync UI (`SquareSyncStatusPanel`, Integrations pages) | ✅ Merged |
| **Stage 16A** | **Error Logging, Crash Capture & Bug Reporting** | ✅ Merged |
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | 🔄 **Next Stage** |
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] | | **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
| **Stage 16B** | Secure Credential Storage & Auth Guard | ⏳ Planned — Production blocker |
| **Stage 16C** | Retry/Rate Limiting & Remote Diagnostics | ⏳ Planned — Staging blocker |

View File

@ -6,12 +6,52 @@
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker - audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean - merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25) - main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
- active branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout - merged: `feat/square-mobile-pos` (Stage 10C POS) — merged to main, commit `7bf8f55`
- active branch: `feat/observability-error-logging` (Stage 16A) — error logging, crash capture, and bug reporting
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data - next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
- total tests on branch: 1373/1373 passing (+45 new: 36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); dart analyze clean across all packages - total tests on branch: 1475/1475 passing (+85 new: 56 core + 10 design_system + 10 kell_web + 9 kell_mobile); dart analyze --fatal-infos clean across all packages
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
## Slice tracker ## Slice tracker
### feat/observability-error-logging
- status: ready for merge
- branch: `feat/observability-error-logging`
- stage: 16A — Error logging, crash reporting, and bug reporting
- date: 2026-08-29
- inspection: complete
- implementation: complete
- closes audit findings: P3.4 (structured logging), P3.5 (crash reporting), and the "No logging infrastructure" / "No crash reporting" architectural risks in `PROJECT_STATE_AUDIT.md` §3.4
- files changed:
- `core/lib/src/observability/kc_log_level.dart` — new `KcLogLevel` enum (debug/info/warning/error/fatal) with `severity`, `label`, `isAtLeast()`, `fromString()`
- `core/lib/src/observability/kc_log_record.dart` — new immutable `KcLogRecord` (level, source, message, timestamp, unmodifiable context, error, stackTrace); `format()`, `toJson()`, `isFailure`
- `core/lib/src/observability/kc_redactor.dart` — new `KcRedactor`; `isSensitiveKey()`, `redactMap()` (recurses nested maps/lists), `maskValue()`; 14 sensitive key fragments
- `core/lib/src/observability/kc_log_sink.dart` — new `KcLogSink` contract; `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer with `recent()`/`recordsAtLeast()`/`clear()`), `KcMultiLogSink` (fault-tolerant fan-out)
- `core/lib/src/observability/kc_logger.dart` — new `KcLogger` facade; level helpers, threshold filtering, automatic context redaction, `forSource()`, `KcLogger.silent()`; never throws
- `core/lib/src/observability/kc_error_reporter.dart` — new `KcErrorReporter`; `install()` wires `FlutterError.onError` + `PlatformDispatcher.instance.onError`, `runGuarded()` wraps `runApp`, `restore()` for test isolation
- `core/lib/src/observability/kc_bug_report.dart` — new `KcBugSeverity` enum and `KcBugReport` model with redacted diagnostics, `hasFailureEvidence`, `toJson()`, `toMarkdown()`
- `core/lib/src/observability/kc_bug_report_service.dart` — new `KcBugReportResult`, `KcBugReportSink` contract, `KcInMemoryBugReportSink`, and `KcBugReportService` (validation, log-tail evidence, graceful sink-failure handling)
- `core/lib/src/observability/kc_observability.dart` — new `KcObservability` bundle; `standard()` and `silent()` factories guarantee logger and bug report service share one evidence buffer
- `core/lib/core.dart` — barrel updated with all 9 observability exports
- `design_system/lib/src/widgets/kc_bug_report_dialog.dart` — new `KcBugReportDraft` and `KcBugReportDialog` (title/description/severity/reporter, inline validation, no-credentials notice)
- `design_system/lib/design_system.dart` — barrel updated
- `kell_web/lib/composition/app_services.dart` — `observability` field + `logger`/`bugReportService` accessors + `appVersion`; all 4 factories stamp environment labels (FAKE/WP/SQ/MULTI)
- `kell_mobile/lib/composition/mobile_app_services.dart` — mirrored observability wiring across all 4 factories
- `kell_web/lib/main.dart` — `KcErrorReporter.install()` + `runGuarded()` + startup record
- `kell_mobile/lib/main.dart` — mirrored error handler installation and startup record
- `kell_web/lib/shell/app_shell.dart` — `_ReportProblemAction` app bar action wired to `bugReportService`
- `kell_mobile/lib/shell/mobile_shell.dart` — `_reportProblem()` app bar action capturing the active tab
- tests added:
- `core/test/observability_test.dart` — 56 tests across `KcLogLevel`, `KcRedactor`, `KcLogRecord`, `KcInMemoryLogSink`, `KcMultiLogSink`, `KcLogger`, `KcErrorReporter`, `KcBugSeverity`, `KcBugReport`, `KcBugReportService`
- `design_system/test/kc_bug_report_dialog_test.dart` — 10 tests (rendering, screen context, validation, trimming, severity selection, cancel)
- `kell_web/test/shell/bug_report_action_test.dart` — 10 tests (service wiring, shared buffer, environment labels, shell submission flow)
- `kell_mobile/test/shell/bug_report_action_test.dart` — 9 tests (service wiring, shared buffer, shell submission flow, active-tab capture)
- tests: passed (1475/1475 total; core 90/90 +56; design_system 51/51 +10; kell_web 62/62 +10; kell_mobile 81/81 +9)
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages and apps)
- brief updated: yes
### feat/square-mobile-pos ### feat/square-mobile-pos
- status: in progress (ready to merge) - status: in progress (ready to merge)
@ -723,4 +763,4 @@
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests - `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages) - tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages) - analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
- brief updated: yes - brief updated: yes

View File

@ -130,6 +130,8 @@ Rules:
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25). - ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25).
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25). - ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25). - ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
- ✅ Square Mobile POS / Checkout landed (Stage 10C-POS complete — merged `feat/square-mobile-pos` → `main`, commit `7bf8f55`, 2026-07-25).
- ✅ Platform observability activated (Stage 16A complete — branch `feat/observability-error-logging`, 2026-08-29). Structured logging (`KcLogger`, `KcLogRecord`, `KcLogLevel`), log sinks (`KcConsoleLogSink`, `KcInMemoryLogSink`, `KcMultiLogSink`), credential redaction (`KcRedactor`), global uncaught-error capture (`KcErrorReporter`), and operator bug reporting (`KcBugReport`, `KcBugReportService`, `KcBugReportDialog`) wired into both app shells.
### Current narrow edit capabilities on `main` ### Current narrow edit capabilities on `main`
@ -141,24 +143,25 @@ Rules:
### Latest known validation state on `main` ### Latest known validation state on `main`
> **Last verified:** 2026-07-25 — Comprehensive State Audit (`chore/audit-state-sync`) > **Last verified:** 2026-08-29 — Release Readiness Review (`feat/observability-error-logging`)
- `dart analyze --fatal-infos` clean across all 11 packages/apps - `dart analyze --fatal-infos` clean across all 11 packages/apps
- All packages passing - All packages passing
- latest reported count for `core`: `24/24 passed` - latest reported count for `core`: `90/90 passed` ← +56 new (Stage 16A observability)
- latest reported count for `design_system`: `41/41 passed` - latest reported count for `design_system`: `51/51 passed` ← +10 new (Stage 16A bug report dialog)
- latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C) - latest reported count for `feature_inventory`: `167/167 passed`
- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS) - latest reported count for `feature_orders`: `234/234 passed`
- latest reported count for `feature_wordpress`: `545/545 passed` - latest reported count for `feature_wordpress`: `545/545 passed`
- latest reported count for `kell_web`: `52/52 passed` - latest reported count for `kell_web`: `62/62 passed` ← +10 new (Stage 16A shell wiring)
- latest reported count for `integrations`: `123/123 passed` - latest reported count for `integrations`: `123/123 passed`
- latest reported count for `data`: `63/63 passed` - latest reported count for `data`: `63/63 passed`
- latest reported count for `auth`: `42/42 passed` - latest reported count for `auth`: `42/42 passed`
- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS) - latest reported count for `kell_mobile`: `81/81 passed` ← +9 new (Stage 16A shell wiring)
- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`) - latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge - total: `1,475/1,475 passed` (+85 new from Stage 16A branch `feat/observability-error-logging`)
- active branch: `feat/observability-error-logging` (Stage 16A) — ready for merge
> **Note:** Stage 10D (`feat/square-catalog-sync-service`) is complete and ready for merge. The `feature_wordpress` count of 506/506 reflects the post-merge state. The `main` baseline currently shows 475/475 for `feature_wordpress` until Stage 10D is merged. > **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin — all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker.
#### Baseline test coverage (established 2026-05-22) #### Baseline test coverage (established 2026-05-22)
@ -233,6 +236,41 @@ Delivered:
- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages - 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages
- Analyze clean - Analyze clean
### Stage 16A — complete (2026-08-29)
**`feat/observability-error-logging`** — Error logging, crash capture, and bug reporting
Closes audit findings P3.4 (structured logging), P3.5 (crash reporting), and the
"No logging infrastructure" / "No crash reporting" architectural risks.
Delivered in `core`:
- `KcLogLevel` — five ordered severities (`debug`, `info`, `warning`, `error`, `fatal`) with threshold comparison
- `KcLogRecord` — immutable structured record with `format()`, `toJson()`, and `isFailure`
- `KcRedactor` — credential redaction applied to every logged context map, traversing nested maps and lists
- `KcLogSink` contract with `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer), and `KcMultiLogSink` (fault-tolerant fan-out)
- `KcLogger` — the logging facade; drops sub-threshold records, redacts context, and never throws
- `KcErrorReporter` — installs `FlutterError.onError`, `PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`
- `KcBugReport` / `KcBugSeverity` — the bug report model with redacted diagnostics, `toJson()`, and `toMarkdown()`
- `KcBugReportSink` contract + `KcInMemoryBugReportSink`
- `KcBugReportService` — validates input, attaches the recent log tail as evidence, and degrades gracefully when the tracker is unreachable
- `KcObservability` — assembles logger, evidence buffer, and bug report service so they always share one buffer
Delivered in `design_system`:
- `KcBugReportDialog` + `KcBugReportDraft` — the shared operator reporting form with inline validation and a stated no-credentials guarantee
Wired into both app targets:
- `KcObservability` held by `AppServices` and `MobileAppServices`; every factory stamps its environment label (`FAKE`, `WP`, `SQ`, `MULTI`)
- `KcErrorReporter.install(...)` + `runGuarded(...)` in both `main.dart` entry points, with a startup record capturing the effective environment and any fake-mode fallback
- "Report a problem" action in the `AppShell` app bar and the `MobileShell` app bar, capturing the active screen
Validation:
- 85 new tests (56 `core`, 10 `design_system`, 10 `kell_web`, 9 `kell_mobile`); 1,475 total
- `dart analyze --fatal-infos` clean across all packages and apps
### Next recommended branch ### Next recommended branch
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data **`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
@ -312,11 +350,11 @@ These items harden the platform for production deployment.
| ID | Item | Target Stage | | ID | Item | Target Stage |
| ----- | --------------------------------------------------------------------------------------- | ----------------- | | ----- | --------------------------------------------------------------------------------------- | ----------------- |
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16A | | P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16C |
| P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B | | P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
| P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B | | P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B |
| P3.4 | Structured logging infrastructure (`package:logging`, replace silent `catch (_) {}`) | Stage 16A | | P3.4 | ~~Structured logging infrastructure~~ - DONE: `KcLogger` (Stage 16A) | Complete |
| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A | | P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete |
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone | | P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
| P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone | | P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone |
| P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone | | P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone |
@ -327,14 +365,18 @@ These items harden the platform for production deployment.
### Key technical debt findings ### Key technical debt findings
- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** — `KC_ENV=square` silently falls to fake mode (CRITICAL) - ~~**`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory**~~ — ✅ RESOLVED in Stage 10E; both factories exist and `KcBootstrap` validates Square credentials
- **`SquareCatalogSyncService` ID conflation** — `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH) - ~~**`SquareCatalogSyncService` ID conflation**~~ — ✅ RESOLVED in Stage 10E; `syncCatalog()` now reads `draft.squareCatalogObjectId`
- ~~**No logging infrastructure**~~ — ✅ RESOLVED in Stage 16A; `KcLogger` with structured records and credential redaction
- ~~**No crash reporting**~~ — ✅ RESOLVED in Stage 16A; `KcErrorReporter` captures framework, platform, and zone errors
- **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH) - **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH)
- **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH) - **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH)
- **No remote log or crash sink** — diagnostics are in-process only and lost on restart; unavailable off-device (HIGH)
- **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM) - **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM)
- **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM) - **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM)
- **`feature_policy` has only a smoke test** — no controller or widget tests (MEDIUM) - **`feature_policy` has only a smoke test** — 17 tests in one file; no controller or widget tests (MEDIUM)
- **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM) - **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM)
- **No dependency injection / state management framework** — `ChangeNotifier` + direct construction will strain as the sync engine and offline queue land (MEDIUM)
--- ---
@ -1202,7 +1244,10 @@ Harden the platform for production deployment beyond a single operator.
- `feat/error-tracking` - `feat/error-tracking`
- `feat/secure-deployment` - `feat/secure-deployment`
#### Stage 16A — Error tracking and observability #### ~~Stage 16A — Error tracking and observability~~ ✅ COMPLETE
> Delivered on `feat/observability-error-logging` (2026-08-29).
> See "Stage 16A — complete (2026-08-29)" above for the full manifest.
##### Goal ##### Goal
@ -1210,10 +1255,25 @@ Add crash reporting, error tracking, and basic usage analytics.
##### Requirements ##### Requirements
- Integrate lightweight crash reporting (e.g., Sentry) - ✅ Crash/error capture — `KcErrorReporter` installs `FlutterError.onError`,
- Add structured logging for API calls, sync operations, and errors `PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`.
- Add basic usage analytics (feature usage frequency, sync success rates) Implemented as a first-party abstraction rather than binding directly to
- Add health check dashboard showing integration status per channel Sentry so no vendor dependency is introduced before the deployment model is
settled; `KcLogSink` is the extension point for a hosted backend.
- ✅ Structured logging for errors and state transitions — `KcLogger` with
five severities, threshold filtering, and mandatory credential redaction.
- ⏳ Usage analytics (feature usage frequency, sync success rates) — **deferred**.
`KcLogRecord` carries the structured `context` needed to derive these, but no
aggregation or reporting surface exists yet.
- ⏳ Health check dashboard per channel — **deferred**. The `IntegrationHealthCheck`
contract exists in `integrations` but has no implementation or UI.
##### Remaining scope (tracked as Stage 16C)
- Remote log/crash sink (`KcLogSink` implementation posting to a backend)
- Integration health dashboard driven by `IntegrationHealthCheck`
- Usage analytics aggregation
- In-app log viewer for operator self-service diagnostics
#### Stage 16B — Secure deployment configuration #### Stage 16B — Secure deployment configuration
@ -1228,6 +1288,25 @@ Move beyond `--dart-define` for credential management in production.
- Connection testing UI (verify WooCommerce and Square connectivity) - Connection testing UI (verify WooCommerce and Square connectivity)
- Environment-specific configuration (dev/staging/production) - Environment-specific configuration (dev/staging/production)
#### Stage 16C — Resilience and remote diagnostics
##### Goal
Make integrations survive transient failure, and make diagnostics visible off-device.
##### Requirements
- Wire `ExponentialBackoffRetryPolicy` into `SquareApiClient` and
`WooCommerceApiClient` for 5xx and network errors (P3.1)
- Wire `TokenBucketRateLimiter` into Square calls — Square enforces rate limits
and currently a burst sync can be throttled with no recovery
- Implement `IntegrationHealthCheck` per channel and surface it on the
Integrations page
- Remote `KcLogSink` implementation so `ERROR`/`FATAL` records and submitted bug
reports leave the device
- In-app log viewer for operator self-service diagnostics
- Usage analytics aggregation (feature usage frequency, sync success rates)
--- ---
## Suggested improvements (identified from codebase analysis) ## Suggested improvements (identified from codebase analysis)
@ -1289,15 +1368,17 @@ Architecture documentation (`traceability-index.md` coverage gaps) identifies "P
**Recommendation:** Addressed in **Stage 9C — WooCommerce catalog expansion** which includes image upload, gallery management, and product image assignment via the WooCommerce Media API. **Recommendation:** Addressed in **Stage 9C — WooCommerce catalog expansion** which includes image upload, gallery management, and product image assignment via the WooCommerce Media API.
### 8. Error tracking and observability ### 8. ~~Error tracking and observability~~ ✅ PARTIALLY RESOLVED
No monitoring, error tracking, or analytics strategy exists for the Flutter applications. In production use: > Resolved for error capture and diagnostics by **Stage 16A** (2026-08-29).
> Runtime errors are now captured by `KcErrorReporter`, recorded through
- Runtime errors are invisible unless the operator sees them > `KcLogger`, and attachable to operator bug reports via `KcBugReportService`.
- No crash reporting > Credential redaction (`KcRedactor`) is enforced on every logged context map.
- No usage analytics to guide prioritization >
> **Still open:** logs and reports are held in-process only — there is no remote
**Recommendation:** Addressed in **Stage 16A — Error tracking and observability**. > sink, so diagnostics are lost when the app restarts and are unavailable to
> anyone but the operator on the device. Usage analytics remain unimplemented.
> Both are tracked as **Stage 16C**.
### 9. ~~Empty tools directory~~ ✅ RESOLVED ### 9. ~~Empty tools directory~~ ✅ RESOLVED
@ -1507,12 +1588,14 @@ Working rules:
### `KC_ENV` values ### `KC_ENV` values
| Value | Description | Status | | Value | Description | Status |
| ----------- | -------------------------------- | ---------------------------------------------------------------- | | ----------- | -------------------------------- | --------------------------------------------------------------------- |
| `fake` | All fake repositories | ✅ Active | | `fake` | All fake repositories | ✅ Active |
| `wordpress` | WooCommerce real + others fake | ✅ Active | | `wordpress` | WooCommerce real + others fake | ✅ Active |
| `square` | Square real + others fake | ⚠️ Keys defined (Stage 10A); factory constructors missing (P1.1) | | `square` | Square real + others fake | ✅ Active — `square()` factory + `KcBootstrap` validation (Stage 10E) |
| `multi` | Both WooCommerce and Square real | ⚠️ Enum value defined; factory constructors missing (P1.1) | | `multi` | Both WooCommerce and Square real | ✅ Active — `multi()` factory + `KcBootstrap` validation (Stage 10E) |
> **Credential handling:** All four modes fall back to `fake` with a logged warning when required credentials are absent. Credential values are never written to logs or bug reports — `KcRedactor` masks any context key containing `token`, `secret`, `key`, `auth`, or `credential`.
--- ---

View File

@ -8,6 +8,7 @@ This section contains operational runbooks, CI/CD documentation, and business pr
| ------------------------------------------------- | ---------------------------------------------------- | ---------------- | | ------------------------------------------------- | ---------------------------------------------------- | ---------------- |
| [CI/CD Workflow](cicd-workflow.md) | Defines the documentation publishing pipeline | ✅ Comprehensive | | [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 | | [Architecture Workflow](architecture-workflow.md) | Defines the diagram authoring and publishing process | ✅ Complete |
| [Release Checklist](release-checklist.md) | Per-environment release gates and sign-off record | ✅ Complete |
## Analysis Findings ## Analysis Findings

View File

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

View File

@ -43,7 +43,11 @@ class MobileAppServices extends KcAppServices {
/// In square/multi mode this could be populated from the Square catalog. /// In square/multi mode this could be populated from the Square catalog.
final List<OrderItem> checkoutCatalogue; final List<OrderItem> checkoutCatalogue;
const MobileAppServices({ /// The observability stack: structured logger, in-memory evidence buffer,
/// and the bug report service backing the shell's "Report a problem" action.
final KcObservability observability;
MobileAppServices({
required this.inventoryRepository, required this.inventoryRepository,
required this.ordersRepository, required this.ordersRepository,
required this.policyRepository, required this.policyRepository,
@ -51,7 +55,27 @@ class MobileAppServices extends KcAppServices {
required this.paymentService, required this.paymentService,
this.squareSyncController, this.squareSyncController,
this.checkoutCatalogue = _fakeCatalogue, this.checkoutCatalogue = _fakeCatalogue,
}); KcObservability? observability,
}) : observability = observability ?? _defaultObservability('FAKE');
/// The application version reported in bug reports.
///
/// Kept in sync with `version:` in `pubspec.yaml`.
static const String appVersion = '1.0.0+1';
/// Convenience accessor for the shared structured logger.
KcLogger get logger => observability.logger;
/// Convenience accessor for the bug report service.
KcBugReportService get bugReportService => observability.bugReportService;
static KcObservability _defaultObservability(String environmentLabel) =>
KcObservability.standard(
appVersion: appVersion,
environment: environmentLabel,
platform: 'android',
source: 'kell_mobile',
);
/// Creates a [MobileAppServices] backed by fake, in-memory repositories. /// Creates a [MobileAppServices] backed by fake, in-memory repositories.
factory MobileAppServices.fake() { factory MobileAppServices.fake() {
@ -61,6 +85,7 @@ class MobileAppServices extends KcAppServices {
policyRepository: FakePolicyRepository(), policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(), productPublishingRepository: FakeProductPublishingRepository(),
paymentService: FakePaymentService(), paymentService: FakePaymentService(),
observability: _defaultObservability('FAKE'),
); );
} }
@ -84,6 +109,7 @@ class MobileAppServices extends KcAppServices {
policyRepository: FakePolicyRepository(), policyRepository: FakePolicyRepository(),
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
paymentService: FakePaymentService(), paymentService: FakePaymentService(),
observability: _defaultObservability('WP'),
); );
} }
@ -116,6 +142,7 @@ class MobileAppServices extends KcAppServices {
productPublishingRepository: catalogRepo, productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService), squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(), paymentService: FakePaymentService(),
observability: _defaultObservability('SQ'),
); );
} }
@ -160,6 +187,7 @@ class MobileAppServices extends KcAppServices {
productPublishingRepository: wcPublishingRepo, productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService), squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(), paymentService: FakePaymentService(),
observability: _defaultObservability('MULTI'),
); );
} }

View File

@ -11,11 +11,28 @@ void main() {
MobileAppServices.serviceFactory, MobileAppServices.serviceFactory,
); );
runApp( // Route uncaught framework and platform errors into the structured logger
KcAppScope<MobileAppServices>( // before the first frame, so startup failures are captured too.
services: services, KcErrorReporter.install(services.logger);
config: effectiveConfig,
child: const KellMobileApp(), services.logger.info(
), 'Application starting',
source: 'main',
context: {
'environment': effectiveConfig.environment.key,
'requestedEnvironment': config.environment.key,
'fellBackToFake': config.environment != effectiveConfig.environment,
'appVersion': MobileAppServices.appVersion,
},
); );
KcErrorReporter.runGuarded(services.logger, () {
runApp(
KcAppScope<MobileAppServices>(
services: services,
config: effectiveConfig,
child: const KellMobileApp(),
),
);
});
} }

View File

@ -1,4 +1,5 @@
import 'package:core/core.dart'; import 'package:core/core.dart';
import 'package:design_system/design_system.dart';
import 'package:feature_orders/feature_orders.dart'; import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart'; import 'package:feature_policy/feature_policy.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -67,6 +68,37 @@ class _MobileShellState extends State<MobileShell> {
super.dispose(); super.dispose();
} }
/// Opens the shared bug report dialog and submits the resulting draft with
/// the current screen and recent log tail attached as evidence.
Future<void> _reportProblem() async {
final services = KcAppScope.of<MobileAppServices>(context);
final screen = _titles[_selectedIndex];
final draft = await KcBugReportDialog.show(context, screen: screen);
if (draft == null || !mounted) return;
final result = await services.bugReportService.submit(
title: draft.title,
description: draft.description,
severity: KcBugSeverity.fromString(draft.severityName),
reporter: draft.reporter,
screen: screen,
diagnostics: {'tabIndex': _selectedIndex},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.succeeded
? 'Report ${result.report!.id} submitted. Thank you.'
: result.errorMessage ?? 'Could not submit the report.',
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final config = KcAppScope.configOf<MobileAppServices>(context); final config = KcAppScope.configOf<MobileAppServices>(context);
@ -75,6 +107,11 @@ class _MobileShellState extends State<MobileShell> {
appBar: AppBar( appBar: AppBar(
title: Text(_titles[_selectedIndex]), title: Text(_titles[_selectedIndex]),
actions: [ actions: [
IconButton(
icon: const Icon(Icons.bug_report_outlined),
tooltip: 'Report a problem',
onPressed: _reportProblem,
),
_EnvironmentBadge(environment: config.environment), _EnvironmentBadge(environment: config.environment),
const SizedBox(width: 12), const SizedBox(width: 12),
], ],

View File

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

View File

@ -28,13 +28,37 @@ class AppServices extends KcAppServices {
/// Present only in `square` and `multi` environments. /// Present only in `square` and `multi` environments.
final SquareSyncController? squareSyncController; final SquareSyncController? squareSyncController;
const AppServices({ /// The observability stack: structured logger, in-memory evidence buffer,
/// and the bug report service backing the shell's "Report a problem" action.
final KcObservability observability;
AppServices({
required this.inventoryRepository, required this.inventoryRepository,
required this.ordersRepository, required this.ordersRepository,
required this.policyRepository, required this.policyRepository,
required this.productPublishingRepository, required this.productPublishingRepository,
this.squareSyncController, this.squareSyncController,
}); KcObservability? observability,
}) : observability = observability ?? _defaultObservability('FAKE');
/// The application version reported in bug reports.
///
/// Kept in sync with `version:` in `pubspec.yaml`.
static const String appVersion = '1.0.0+1';
/// Convenience accessor for the shared structured logger.
KcLogger get logger => observability.logger;
/// Convenience accessor for the bug report service.
KcBugReportService get bugReportService => observability.bugReportService;
static KcObservability _defaultObservability(String environmentLabel) =>
KcObservability.standard(
appVersion: appVersion,
environment: environmentLabel,
platform: 'web',
source: 'kell_web',
);
/// Creates an [AppServices] backed by fake, in-memory repositories. /// Creates an [AppServices] backed by fake, in-memory repositories.
factory AppServices.fake() { factory AppServices.fake() {
@ -43,6 +67,7 @@ class AppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(), ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(), policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(), productPublishingRepository: FakeProductPublishingRepository(),
observability: _defaultObservability('FAKE'),
); );
} }
@ -69,6 +94,7 @@ class AppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(), ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(), policyRepository: FakePolicyRepository(),
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
observability: _defaultObservability('WP'),
); );
} }
@ -100,6 +126,7 @@ class AppServices extends KcAppServices {
policyRepository: FakePolicyRepository(), policyRepository: FakePolicyRepository(),
productPublishingRepository: catalogRepo, productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService), squareSyncController: SquareSyncController(syncService: syncService),
observability: _defaultObservability('SQ'),
); );
} }
@ -142,6 +169,7 @@ class AppServices extends KcAppServices {
policyRepository: FakePolicyRepository(), policyRepository: FakePolicyRepository(),
productPublishingRepository: wcPublishingRepo, productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService), squareSyncController: SquareSyncController(syncService: syncService),
observability: _defaultObservability('MULTI'),
); );
} }

View File

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

View File

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

View File

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

View File

@ -8,6 +8,15 @@
/// - [KcServiceFactory] — generic factory for environment-aware service construction /// - [KcServiceFactory] — generic factory for environment-aware service construction
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation /// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree /// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
///
/// It also provides the shared observability stack used by every app target:
///
/// - [KcLogger] / [KcLogRecord] / [KcLogLevel] — structured logging
/// - [KcLogSink] implementations — console, bounded in-memory buffer, fan-out
/// - [KcErrorReporter] — global uncaught-error capture
/// - [KcBugReportService] / [KcBugReport] — operator bug reporting with
/// diagnostic evidence attached
/// - [KcRedactor] — credential redaction applied to all logged context
library; library;
// Composition // Composition
@ -15,3 +24,14 @@ export 'src/composition/kc_app_config.dart';
export 'src/composition/kc_app_scope.dart'; export 'src/composition/kc_app_scope.dart';
export 'src/composition/kc_app_services.dart'; export 'src/composition/kc_app_services.dart';
export 'src/composition/kc_bootstrap.dart'; export 'src/composition/kc_bootstrap.dart';
// Observability
export 'src/observability/kc_bug_report.dart';
export 'src/observability/kc_bug_report_service.dart';
export 'src/observability/kc_error_reporter.dart';
export 'src/observability/kc_log_level.dart';
export 'src/observability/kc_log_record.dart';
export 'src/observability/kc_log_sink.dart';
export 'src/observability/kc_logger.dart';
export 'src/observability/kc_observability.dart';
export 'src/observability/kc_redactor.dart';

View File

@ -0,0 +1,160 @@
/// The bug report domain model.
library;
import 'kc_log_record.dart';
import 'kc_redactor.dart';
/// How severely a reported defect affects the operator.
enum KcBugSeverity {
/// Cosmetic or minor annoyance; work can continue.
low('Low'),
/// Degrades a workflow but a workaround exists.
medium('Medium'),
/// Blocks a business workflow with no workaround.
high('High'),
/// Data loss, incorrect financial figures, or total outage.
critical('Critical');
/// Display label for UI.
final String label;
const KcBugSeverity(this.label);
/// Parses a string into a [KcBugSeverity], defaulting to [medium].
static KcBugSeverity fromString(String value) {
for (final severity in values) {
if (severity.name == value.toLowerCase()) return severity;
}
return medium;
}
}
/// An operator-submitted defect report with attached diagnostic evidence.
///
/// A report bundles what the operator observed with the machine context needed
/// to triage it: the app version, environment mode, platform, and the recent
/// log tail leading up to the failure.
class KcBugReport {
/// Stable identifier used for correlation between the app and the tracker.
final String id;
/// Short summary supplied by the operator.
final String title;
/// Free-text description, including reproduction steps.
final String description;
/// Operator-assessed impact.
final KcBugSeverity severity;
/// When the report was created.
final DateTime createdAt;
/// Application version string (e.g. `1.0.0+1`).
final String appVersion;
/// Runtime environment label (e.g. `FAKE`, `WP`, `SQ`, `MULTI`).
final String environment;
/// Host platform description (e.g. `android`, `web`).
final String platform;
/// Optional reporter identity (email or operator name).
final String? reporter;
/// The screen or workflow the operator was on when the defect occurred.
final String? screen;
/// Recent structured log records attached as evidence.
final List<KcLogRecord> logTail;
/// Additional structured diagnostics. Redacted on construction.
final Map<String, Object?> diagnostics;
KcBugReport({
required this.id,
required this.title,
required this.description,
required this.severity,
required this.createdAt,
required this.appVersion,
required this.environment,
required this.platform,
this.reporter,
this.screen,
List<KcLogRecord> logTail = const [],
Map<String, Object?> diagnostics = const {},
}) : logTail = List.unmodifiable(logTail),
diagnostics = Map.unmodifiable(KcRedactor.redactMap(diagnostics));
/// Whether the attached evidence contains at least one failure record.
bool get hasFailureEvidence => logTail.any((r) => r.isFailure);
/// A JSON-serialisable payload suitable for transmission to a tracker.
Map<String, Object?> toJson() => {
'id': id,
'title': title,
'description': description,
'severity': severity.name,
'createdAt': createdAt.toIso8601String(),
'appVersion': appVersion,
'environment': environment,
'platform': platform,
if (reporter != null) 'reporter': reporter,
if (screen != null) 'screen': screen,
if (diagnostics.isNotEmpty) 'diagnostics': diagnostics,
'logTail': logTail.map((r) => r.toJson()).toList(),
};
/// A human-readable rendering used for clipboard export and issue bodies.
String toMarkdown() {
final buffer = StringBuffer()
..writeln('# Bug Report: $title')
..writeln()
..writeln('- **ID:** `$id`')
..writeln('- **Severity:** ${severity.label}')
..writeln('- **Reported:** ${createdAt.toIso8601String()}')
..writeln('- **App version:** $appVersion')
..writeln('- **Environment:** $environment')
..writeln('- **Platform:** $platform');
if (reporter != null) buffer.writeln('- **Reporter:** $reporter');
if (screen != null) buffer.writeln('- **Screen:** $screen');
buffer
..writeln()
..writeln('## Description')
..writeln()
..writeln(description);
if (diagnostics.isNotEmpty) {
buffer
..writeln()
..writeln('## Diagnostics')
..writeln();
for (final entry in diagnostics.entries) {
buffer.writeln('- `${entry.key}`: ${entry.value}');
}
}
if (logTail.isNotEmpty) {
buffer
..writeln()
..writeln('## Recent log records')
..writeln()
..writeln('```');
for (final record in logTail) {
buffer.writeln(record.format());
}
buffer.writeln('```');
}
return buffer.toString();
}
@override
String toString() => 'KcBugReport($id, ${severity.label}: $title)';
}

View File

@ -0,0 +1,197 @@
/// Bug report submission contracts and the default in-memory implementation.
library;
import 'kc_bug_report.dart';
import 'kc_log_level.dart';
import 'kc_log_sink.dart';
import 'kc_logger.dart';
/// The outcome of a [KcBugReportService.submit] call.
class KcBugReportResult {
/// Whether the report was accepted by the sink.
final bool succeeded;
/// The submitted report, present on success.
final KcBugReport? report;
/// Failure detail, present when [succeeded] is `false`.
final String? errorMessage;
const KcBugReportResult.success(this.report) : succeeded = true, errorMessage = null;
const KcBugReportResult.failure(this.errorMessage) : succeeded = false, report = null;
@override
String toString() => succeeded
? 'KcBugReportResult.success(${report?.id})'
: 'KcBugReportResult.failure($errorMessage)';
}
/// A destination that persists or transmits submitted [KcBugReport]s.
///
/// Implementations may write to local storage, POST to a Forgejo issue
/// endpoint, or forward to a crash-reporting backend. Keeping this abstract
/// means the app never depends on a specific tracker.
abstract class KcBugReportSink {
/// Persists or transmits [report]. Throws on failure.
Future<void> send(KcBugReport report);
}
/// An in-memory [KcBugReportSink] used in fake mode and in tests.
///
/// Retains every submitted report so the UI can display a submission history
/// without a backend.
class KcInMemoryBugReportSink implements KcBugReportSink {
final List<KcBugReport> _reports = [];
/// All reports submitted so far, oldest first.
List<KcBugReport> get reports => List.unmodifiable(_reports);
@override
Future<void> send(KcBugReport report) async {
_reports.add(report);
}
/// Discards all retained reports.
void clear() => _reports.clear();
}
/// Creates and submits [KcBugReport]s with diagnostic evidence attached.
///
/// The service is the single entry point used by both app shells. It stamps
/// each report with build/environment metadata, attaches the recent log tail
/// from [logSink], and records the submission outcome through [logger] so a
/// failed submission is itself observable.
class KcBugReportService {
/// Where submitted reports are sent.
final KcBugReportSink sink;
/// Structured logger used to record submission attempts.
final KcLogger logger;
/// Optional buffer supplying the recent log tail attached as evidence.
final KcInMemoryLogSink? logSink;
/// Application version stamped onto every report.
final String appVersion;
/// Environment label stamped onto every report.
final String environment;
/// Platform label stamped onto every report.
final String platform;
/// Number of recent log records attached to each report.
final int logTailSize;
final DateTime Function() _clock;
final String Function()? _idGenerator;
int _sequence = 0;
KcBugReportService({
required this.sink,
required this.logger,
required this.appVersion,
required this.environment,
required this.platform,
this.logSink,
this.logTailSize = 50,
DateTime Function()? clock,
String Function()? idGenerator,
}) : _clock = clock ?? DateTime.now,
_idGenerator = idGenerator;
/// Builds a report without submitting it.
///
/// Useful for previewing the payload (including redacted diagnostics and the
/// attached log tail) before the operator confirms submission.
KcBugReport buildReport({
required String title,
required String description,
KcBugSeverity severity = KcBugSeverity.medium,
String? reporter,
String? screen,
Map<String, Object?> diagnostics = const {},
}) {
return KcBugReport(
id: _nextId(),
title: title,
description: description,
severity: severity,
createdAt: _clock(),
appVersion: appVersion,
environment: environment,
platform: platform,
reporter: reporter,
screen: screen,
logTail: logSink?.recent(logTailSize) ?? const [],
diagnostics: diagnostics,
);
}
/// Builds and submits a report.
///
/// [title] and [description] must not be blank. Validation failures return a
/// [KcBugReportResult.failure] rather than throwing so the UI can surface the
/// message directly.
///
/// Sink failures are caught, logged, and returned as a failure result — a
/// broken tracker must never crash the app while an operator is reporting a
/// different problem.
Future<KcBugReportResult> submit({
required String title,
required String description,
KcBugSeverity severity = KcBugSeverity.medium,
String? reporter,
String? screen,
Map<String, Object?> diagnostics = const {},
}) async {
if (title.trim().isEmpty) {
return const KcBugReportResult.failure('A title is required.');
}
if (description.trim().isEmpty) {
return const KcBugReportResult.failure('A description is required.');
}
final report = buildReport(
title: title.trim(),
description: description.trim(),
severity: severity,
reporter: reporter,
screen: screen,
diagnostics: diagnostics,
);
try {
await sink.send(report);
logger.log(
severity == KcBugSeverity.critical ? KcLogLevel.error : KcLogLevel.info,
'Bug report submitted',
source: 'KcBugReportService',
context: {
'reportId': report.id,
'severity': severity.name,
'logRecordsAttached': report.logTail.length,
},
);
return KcBugReportResult.success(report);
} catch (e, s) {
logger.error(
'Bug report submission failed',
source: 'KcBugReportService',
context: {'reportId': report.id},
error: e,
stackTrace: s,
);
return KcBugReportResult.failure('Could not submit the report: $e');
}
}
String _nextId() {
final generator = _idGenerator;
if (generator != null) return generator();
_sequence++;
return 'KC-BUG-${_clock().millisecondsSinceEpoch}-$_sequence';
}
}

View File

@ -0,0 +1,115 @@
/// Global uncaught-error capture for all Kell Creations app targets.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'kc_logger.dart';
/// Installs global error handlers so no runtime failure goes unrecorded.
///
/// Without this, uncaught widget-build errors are printed to a console nobody
/// reads and asynchronous errors vanish silently. [KcErrorReporter] routes all
/// three failure channels into the structured [KcLogger]:
///
/// 1. `FlutterError.onError` — errors raised inside the Flutter framework
/// (build, layout, paint).
/// 2. `PlatformDispatcher.instance.onError` — uncaught asynchronous errors
/// that reach the engine.
/// 3. [runGuarded] — a guarded zone wrapping `runApp` for anything else.
///
/// ## Usage
///
/// ```dart
/// void main() {
/// final logger = KcLogger(sink: KcConsoleLogSink());
/// KcErrorReporter.install(logger);
/// runApp(const MyApp());
/// }
/// ```
class KcErrorReporter {
const KcErrorReporter._();
/// The `source` tag applied to every globally captured record.
static const String source = 'KcErrorReporter';
/// The previously installed [FlutterError.onError], retained so [restore]
/// can put the framework back to its original state (used by tests).
static FlutterExceptionHandler? _previousFlutterOnError;
/// Whether [install] has been called and not yet undone by [restore].
static bool _installed = false;
/// Whether global handlers are currently installed.
static bool get isInstalled => _installed;
/// Routes framework and platform errors into [logger].
///
/// When [forwardToOriginalHandler] is `true` (the default) the previous
/// `FlutterError.onError` is still invoked, preserving the standard red
/// error screen and console output in debug builds.
///
/// Calling [install] twice without [restore] is a no-op beyond swapping the
/// active logger, so it is safe to call from every app target's `main`.
static void install(KcLogger logger, {bool forwardToOriginalHandler = true}) {
if (!_installed) {
_previousFlutterOnError = FlutterError.onError;
_installed = true;
}
final previous = _previousFlutterOnError;
FlutterError.onError = (FlutterErrorDetails details) {
logger.error(
'Uncaught Flutter framework error',
source: source,
context: {
'library': details.library ?? 'unknown',
'context': details.context?.toDescription() ?? '',
'silent': details.silent,
},
error: details.exception,
stackTrace: details.stack,
);
if (forwardToOriginalHandler && previous != null) {
previous(details);
}
};
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
logger.fatal(
'Uncaught asynchronous error',
source: source,
error: error,
stackTrace: stack,
);
// Returning true marks the error as handled so the app is not killed.
return true;
};
}
/// Runs [body] inside a guarded [Zone] that reports uncaught errors to
/// [logger], then rethrows nothing — the zone absorbs the failure after it
/// has been recorded.
///
/// Wrap `runApp` with this to capture errors that escape both
/// `FlutterError.onError` and the platform dispatcher.
static R? runGuarded<R>(KcLogger logger, R Function() body) {
return runZonedGuarded<R>(body, (Object error, StackTrace stack) {
logger.fatal('Uncaught zone error', source: source, error: error, stackTrace: stack);
});
}
/// Restores the error handlers captured before [install].
///
/// Primarily used by tests so global state does not leak between cases.
static void restore() {
if (!_installed) return;
FlutterError.onError = _previousFlutterOnError;
PlatformDispatcher.instance.onError = null;
_previousFlutterOnError = null;
_installed = false;
}
}

View File

@ -0,0 +1,43 @@
/// Severity levels for structured log records.
///
/// Ordered from least to most severe. [KcLogLevel.severity] is used to
/// filter records against a configured minimum level.
library;
/// The severity of a [KcLogRecord].
enum KcLogLevel {
/// Fine-grained diagnostic detail, normally disabled outside development.
debug('DEBUG', 10),
/// Routine operational events (navigation, sync started, order created).
info('INFO', 20),
/// Recoverable conditions that warrant attention but did not fail the
/// operation (credential fallback, partial sync failure, retry).
warning('WARNING', 30),
/// An operation failed. The user-facing action did not complete.
error('ERROR', 40),
/// An unrecoverable failure. The app or a critical subsystem is unusable.
fatal('FATAL', 50);
/// Uppercase label used in formatted output.
final String label;
/// Numeric severity used for threshold comparisons. Higher is more severe.
final int severity;
const KcLogLevel(this.label, this.severity);
/// Whether this level is at least as severe as [minimum].
bool isAtLeast(KcLogLevel minimum) => severity >= minimum.severity;
/// Parses a string into a [KcLogLevel], defaulting to [info].
static KcLogLevel fromString(String value) {
for (final level in values) {
if (level.name == value.toLowerCase()) return level;
}
return info;
}
}

View File

@ -0,0 +1,83 @@
/// The immutable structured log record emitted by [KcLogger].
library;
import 'kc_log_level.dart';
/// A single structured log entry.
///
/// Records are immutable value objects so they can be safely buffered,
/// forwarded to multiple sinks, and attached to bug reports.
class KcLogRecord {
/// Severity of the event.
final KcLogLevel level;
/// Logical source of the event, e.g. `SquareSyncController`.
///
/// Used to group and filter records by subsystem.
final String source;
/// Human-readable description of what happened.
final String message;
/// When the event occurred.
final DateTime timestamp;
/// Structured key/value context (ids, counts, durations).
///
/// Must not contain secrets — see [KcRedactor] for sanitising values
/// before they are attached.
final Map<String, Object?> context;
/// The associated error object, when the record describes a failure.
final Object? error;
/// The stack trace captured at the point of failure.
final StackTrace? stackTrace;
KcLogRecord({
required this.level,
required this.source,
required this.message,
required this.timestamp,
Map<String, Object?> context = const {},
this.error,
this.stackTrace,
}) : context = Map.unmodifiable(context);
/// Whether this record describes a failure (has an [error] attached or is
/// at [KcLogLevel.error] severity or above).
bool get isFailure => error != null || level.isAtLeast(KcLogLevel.error);
/// A single-line, human-readable rendering used by console sinks.
String format() {
final buffer = StringBuffer()
..write('[${timestamp.toIso8601String()}] ')
..write('${level.label.padRight(7)} ')
..write('$source — $message');
if (context.isNotEmpty) {
final pairs = context.entries.map((e) => '${e.key}=${e.value}').join(', ');
buffer.write(' {$pairs}');
}
if (error != null) {
buffer.write(' | error: $error');
}
return buffer.toString();
}
/// A JSON-serialisable representation for export and bug-report payloads.
///
/// [stackTrace] is rendered as a string so the map stays serialisable.
Map<String, Object?> toJson() => {
'timestamp': timestamp.toIso8601String(),
'level': level.label,
'source': source,
'message': message,
if (context.isNotEmpty) 'context': context,
if (error != null) 'error': error.toString(),
if (stackTrace != null) 'stackTrace': stackTrace.toString(),
};
@override
String toString() => format();
}

View File

@ -0,0 +1,96 @@
/// Destinations for structured log records.
library;
import 'package:flutter/foundation.dart';
import 'kc_log_level.dart';
import 'kc_log_record.dart';
/// A destination that receives [KcLogRecord]s from [KcLogger].
///
/// Implementations must never throw — [KcLogger] treats sinks as
/// fire-and-forget so a broken sink can never break a business operation.
abstract class KcLogSink {
/// Handles a single [record].
void write(KcLogRecord record);
}
/// A sink that writes formatted records to the debug console.
///
/// Uses [debugPrint] so output is rate-limited by the Flutter framework and
/// stripped from release builds by default ([enabledInRelease] overrides this
/// for diagnosing production issues on a device).
class KcConsoleLogSink implements KcLogSink {
/// When `false` (the default) the sink is silent in release builds.
final bool enabledInRelease;
const KcConsoleLogSink({this.enabledInRelease = false});
@override
void write(KcLogRecord record) {
if (kReleaseMode && !enabledInRelease) return;
debugPrint(record.format());
final trace = record.stackTrace;
if (trace != null && record.level.isAtLeast(KcLogLevel.error)) {
debugPrint(trace.toString());
}
}
}
/// A bounded, in-memory ring buffer of recent log records.
///
/// This is the evidence source for bug reports: when an operator files a
/// report, the most recent [capacity] records are attached so the failure has
/// context without requiring a remote logging backend.
class KcInMemoryLogSink implements KcLogSink {
/// Maximum number of records retained. Oldest records are evicted first.
final int capacity;
final List<KcLogRecord> _records = [];
KcInMemoryLogSink({this.capacity = 200}) : assert(capacity > 0, 'capacity must be positive');
@override
void write(KcLogRecord record) {
_records.add(record);
while (_records.length > capacity) {
_records.removeAt(0);
}
}
/// All retained records, oldest first.
List<KcLogRecord> get records => List.unmodifiable(_records);
/// Retained records at [minimumLevel] or above, oldest first.
List<KcLogRecord> recordsAtLeast(KcLogLevel minimumLevel) =>
List.unmodifiable(_records.where((r) => r.level.isAtLeast(minimumLevel)));
/// The most recent [count] records, oldest first.
List<KcLogRecord> recent(int count) {
if (count >= _records.length) return records;
return List.unmodifiable(_records.sublist(_records.length - count));
}
/// Discards all retained records.
void clear() => _records.clear();
}
/// A sink that forwards each record to several [sinks].
///
/// A failure in one sink never prevents the others from receiving the record.
class KcMultiLogSink implements KcLogSink {
final List<KcLogSink> sinks;
const KcMultiLogSink(this.sinks);
@override
void write(KcLogRecord record) {
for (final sink in sinks) {
try {
sink.write(record);
} catch (_) {
// A broken sink must never break the calling operation.
}
}
}
}

View File

@ -0,0 +1,150 @@
/// The structured logging facade used across the Kell Creations platform.
library;
import 'kc_log_level.dart';
import 'kc_log_record.dart';
import 'kc_log_sink.dart';
import 'kc_redactor.dart';
/// Emits structured [KcLogRecord]s to a configured [KcLogSink].
///
/// ## Design contract
///
/// - Logging is **never** allowed to throw. A failing sink is swallowed so an
/// observability problem can never become a business-logic failure.
/// - All context is passed through [KcRedactor] so credentials cannot leak
/// into logs or bug reports.
/// - Records below [minimumLevel] are dropped before the sink is invoked.
///
/// ## Usage
///
/// ```dart
/// final logger = KcLogger(sink: KcConsoleLogSink(), source: 'SquareSync');
/// logger.info('Sync started', context: {'itemCount': 42});
///
/// try {
/// await service.syncAll();
/// } catch (e, s) {
/// logger.error('Sync failed', error: e, stackTrace: s);
/// }
/// ```
///
/// Use [forSource] to derive a child logger that tags every record with a
/// subsystem name while sharing the same sink and threshold.
class KcLogger {
/// The destination for emitted records.
final KcLogSink sink;
/// Records below this severity are dropped.
final KcLogLevel minimumLevel;
/// Default `source` applied to records that do not override it.
final String source;
/// Injectable clock, primarily for deterministic tests.
final DateTime Function() _clock;
KcLogger({
required this.sink,
this.minimumLevel = KcLogLevel.info,
this.source = 'app',
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
/// A logger that discards everything. Useful as a default in tests and in
/// constructors where observability is optional.
factory KcLogger.silent() =>
KcLogger(sink: const KcMultiLogSink([]), minimumLevel: KcLogLevel.fatal);
/// Returns a logger that shares this logger's sink, threshold, and clock but
/// tags every record with [source].
KcLogger forSource(String source) =>
KcLogger(sink: sink, minimumLevel: minimumLevel, source: source, clock: _clock);
/// Logs at [KcLogLevel.debug].
void debug(String message, {String? source, Map<String, Object?> context = const {}}) =>
log(KcLogLevel.debug, message, source: source, context: context);
/// Logs at [KcLogLevel.info].
void info(String message, {String? source, Map<String, Object?> context = const {}}) =>
log(KcLogLevel.info, message, source: source, context: context);
/// Logs at [KcLogLevel.warning].
void warning(
String message, {
String? source,
Map<String, Object?> context = const {},
Object? error,
StackTrace? stackTrace,
}) => log(
KcLogLevel.warning,
message,
source: source,
context: context,
error: error,
stackTrace: stackTrace,
);
/// Logs at [KcLogLevel.error].
void error(
String message, {
String? source,
Map<String, Object?> context = const {},
Object? error,
StackTrace? stackTrace,
}) => log(
KcLogLevel.error,
message,
source: source,
context: context,
error: error,
stackTrace: stackTrace,
);
/// Logs at [KcLogLevel.fatal].
void fatal(
String message, {
String? source,
Map<String, Object?> context = const {},
Object? error,
StackTrace? stackTrace,
}) => log(
KcLogLevel.fatal,
message,
source: source,
context: context,
error: error,
stackTrace: stackTrace,
);
/// Emits a record at [level], unless it is below [minimumLevel].
///
/// [context] is redacted before it reaches the sink. Any exception raised by
/// the sink is swallowed.
void log(
KcLogLevel level,
String message, {
String? source,
Map<String, Object?> context = const {},
Object? error,
StackTrace? stackTrace,
}) {
if (!level.isAtLeast(minimumLevel)) return;
try {
sink.write(
KcLogRecord(
level: level,
source: source ?? this.source,
message: message,
timestamp: _clock(),
context: KcRedactor.redactMap(context),
error: error,
stackTrace: stackTrace,
),
);
} catch (_) {
// Observability must never break the caller.
}
}
}

View File

@ -0,0 +1,98 @@
/// The assembled observability stack shared by every app target.
library;
import 'kc_bug_report_service.dart';
import 'kc_log_level.dart';
import 'kc_log_sink.dart';
import 'kc_logger.dart';
/// Bundles the logger, its in-memory evidence buffer, and the bug report
/// service into a single value that app service containers can hold.
///
/// Assembling these together guarantees the three components stay consistent:
/// the buffer wired into the logger is the same buffer the bug report service
/// reads its evidence from. Building them independently is the most likely way
/// to end up with empty bug reports.
///
/// ## Usage
///
/// ```dart
/// final observability = KcObservability.standard(
/// appVersion: '1.0.0+1',
/// environment: config.environmentLabel,
/// platform: 'android',
/// );
///
/// KcErrorReporter.install(observability.logger);
/// observability.logger.info('Bootstrap complete');
/// ```
class KcObservability {
/// Bounded buffer of recent records, attached to bug reports as evidence.
final KcInMemoryLogSink logBuffer;
/// The structured logger used across the app target.
final KcLogger logger;
/// The service backing the shell's "Report a problem" action.
final KcBugReportService bugReportService;
const KcObservability({
required this.logBuffer,
required this.logger,
required this.bugReportService,
});
/// Builds the standard stack: console output plus a bounded in-memory buffer,
/// with an in-memory bug report sink.
///
/// Pass [bugReportSink] to route reports to a real tracker while keeping the
/// rest of the wiring intact.
factory KcObservability.standard({
required String appVersion,
required String environment,
required String platform,
String source = 'app',
KcLogLevel minimumLevel = KcLogLevel.info,
int bufferCapacity = 200,
KcBugReportSink? bugReportSink,
}) {
final logBuffer = KcInMemoryLogSink(capacity: bufferCapacity);
final logger = KcLogger(
sink: KcMultiLogSink([const KcConsoleLogSink(), logBuffer]),
minimumLevel: minimumLevel,
source: source,
);
return KcObservability(
logBuffer: logBuffer,
logger: logger,
bugReportService: KcBugReportService(
sink: bugReportSink ?? KcInMemoryBugReportSink(),
logger: logger,
logSink: logBuffer,
appVersion: appVersion,
environment: environment,
platform: platform,
),
);
}
/// A stack that records nothing, for tests that do not assert on logging.
factory KcObservability.silent() {
final logBuffer = KcInMemoryLogSink();
final logger = KcLogger(sink: logBuffer, minimumLevel: KcLogLevel.fatal);
return KcObservability(
logBuffer: logBuffer,
logger: logger,
bugReportService: KcBugReportService(
sink: KcInMemoryBugReportSink(),
logger: logger,
logSink: logBuffer,
appVersion: 'test',
environment: 'FAKE',
platform: 'test',
),
);
}
}

View File

@ -0,0 +1,71 @@
/// Secret redaction for log context and bug-report payloads.
///
/// The platform passes WooCommerce and Square credentials through
/// `--dart-define`. Those values must never reach a log sink, a diagnostics
/// export, or a bug report. [KcRedactor] is the single chokepoint that
/// enforces this.
library;
/// Removes sensitive values from structured context maps.
class KcRedactor {
/// The replacement written in place of a sensitive value.
static const String mask = '***REDACTED***';
/// Substrings that mark a key as sensitive (case-insensitive).
static const List<String> sensitiveKeyFragments = [
'token',
'secret',
'password',
'passwd',
'consumerkey',
'consumer_key',
'apikey',
'api_key',
'authorization',
'auth',
'credential',
'bearer',
'signature',
'cookie',
];
const KcRedactor._();
/// Returns `true` when [key] names a sensitive value.
static bool isSensitiveKey(String key) {
final normalised = key.toLowerCase().replaceAll('-', '');
return sensitiveKeyFragments.any(normalised.contains);
}
/// Returns a copy of [context] with every sensitive value replaced by [mask].
///
/// Nested maps and lists are traversed so secrets cannot hide inside
/// structured payloads.
static Map<String, Object?> redactMap(Map<String, Object?> context) {
return {
for (final entry in context.entries)
entry.key: isSensitiveKey(entry.key) ? mask : _redactValue(entry.value),
};
}
/// Masks all but the last [visible] characters of [value].
///
/// Used when an identifier is useful for correlation but must not be
/// disclosed in full. Values shorter than or equal to [visible] are fully
/// masked.
static String maskValue(String value, {int visible = 4}) {
if (value.isEmpty) return '';
if (value.length <= visible) return mask;
return '$mask${value.substring(value.length - visible)}';
}
static Object? _redactValue(Object? value) {
if (value is Map) {
return redactMap(value.map((k, v) => MapEntry(k.toString(), v)));
}
if (value is List) {
return value.map(_redactValue).toList();
}
return value;
}
}

View File

@ -0,0 +1,716 @@
import 'package:core/core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
/// A sink that always throws, used to prove logging never breaks callers.
class _ThrowingLogSink implements KcLogSink {
@override
void write(KcLogRecord record) => throw StateError('sink is broken');
}
/// A bug report sink that always fails, used to prove submission degrades
/// gracefully.
class _FailingBugReportSink implements KcBugReportSink {
@override
Future<void> send(KcBugReport report) async => throw StateError('tracker offline');
}
/// A stand-in error type with a stable name for assertions.
class _ApiFailureStub implements Exception {
const _ApiFailureStub();
@override
String toString() => 'ApiFailureStub';
}
final _fixedClock = DateTime.utc(2026, 8, 29, 12, 30);
void main() {
// ── KcLogLevel ─────────────────────────────────────────────────────────────
group('KcLogLevel', () {
test('severity increases from debug to fatal', () {
expect(KcLogLevel.debug.severity, lessThan(KcLogLevel.info.severity));
expect(KcLogLevel.info.severity, lessThan(KcLogLevel.warning.severity));
expect(KcLogLevel.warning.severity, lessThan(KcLogLevel.error.severity));
expect(KcLogLevel.error.severity, lessThan(KcLogLevel.fatal.severity));
});
test('isAtLeast compares severity inclusively', () {
expect(KcLogLevel.error.isAtLeast(KcLogLevel.warning), isTrue);
expect(KcLogLevel.error.isAtLeast(KcLogLevel.error), isTrue);
expect(KcLogLevel.debug.isAtLeast(KcLogLevel.info), isFalse);
});
test('fromString parses known values and defaults to info', () {
expect(KcLogLevel.fromString('warning'), KcLogLevel.warning);
expect(KcLogLevel.fromString('fatal'), KcLogLevel.fatal);
expect(KcLogLevel.fromString('nonsense'), KcLogLevel.info);
});
test('labels are uppercase', () {
expect(KcLogLevel.debug.label, 'DEBUG');
expect(KcLogLevel.fatal.label, 'FATAL');
});
});
// ── KcRedactor ─────────────────────────────────────────────────────────────
group('KcRedactor', () {
test('identifies sensitive keys regardless of case or dashes', () {
expect(KcRedactor.isSensitiveKey('accessToken'), isTrue);
expect(KcRedactor.isSensitiveKey('CONSUMER_SECRET'), isTrue);
expect(KcRedactor.isSensitiveKey('Api-Key'), isTrue);
expect(KcRedactor.isSensitiveKey('Authorization'), isTrue);
expect(KcRedactor.isSensitiveKey('productId'), isFalse);
});
test('redactMap masks sensitive values and preserves safe ones', () {
final redacted = KcRedactor.redactMap({
'squareAccessToken': 'EAAAlreal',
'productId': 42,
'siteUrl': 'https://store.example.com',
});
expect(redacted['squareAccessToken'], KcRedactor.mask);
expect(redacted['productId'], 42);
expect(redacted['siteUrl'], 'https://store.example.com');
});
test('redactMap traverses nested maps and lists', () {
final redacted = KcRedactor.redactMap({
'request': {
'headers': {'authorization': 'Bearer abc'},
'items': [
{'sku': 'A1', 'apiKey': 'shh'},
],
},
});
final request = redacted['request']! as Map<String, Object?>;
final headers = request['headers']! as Map<String, Object?>;
expect(headers['authorization'], KcRedactor.mask);
final items = request['items']! as List<Object?>;
final first = items.first! as Map<String, Object?>;
expect(first['sku'], 'A1');
expect(first['apiKey'], KcRedactor.mask);
});
test('maskValue keeps only the trailing characters', () {
expect(KcRedactor.maskValue('abcdefgh'), '${KcRedactor.mask}efgh');
expect(KcRedactor.maskValue('abc'), KcRedactor.mask);
expect(KcRedactor.maskValue(''), '');
});
});
// ── KcLogRecord ────────────────────────────────────────────────────────────
group('KcLogRecord', () {
test('format includes timestamp, level, source, message and context', () {
final record = KcLogRecord(
level: KcLogLevel.info,
source: 'SquareSync',
message: 'Sync started',
timestamp: _fixedClock,
context: {'itemCount': 3},
);
final formatted = record.format();
expect(formatted, contains('2026-08-29T12:30:00.000Z'));
expect(formatted, contains('INFO'));
expect(formatted, contains('SquareSync'));
expect(formatted, contains('Sync started'));
expect(formatted, contains('itemCount=3'));
});
test('isFailure is true for error level or attached error', () {
final errorRecord = KcLogRecord(
level: KcLogLevel.error,
source: 's',
message: 'm',
timestamp: _fixedClock,
);
final infoWithError = KcLogRecord(
level: KcLogLevel.info,
source: 's',
message: 'm',
timestamp: _fixedClock,
error: StateError('boom'),
);
final plainInfo = KcLogRecord(
level: KcLogLevel.info,
source: 's',
message: 'm',
timestamp: _fixedClock,
);
expect(errorRecord.isFailure, isTrue);
expect(infoWithError.isFailure, isTrue);
expect(plainInfo.isFailure, isFalse);
});
test('toJson is serialisable and includes error and stack trace', () {
final record = KcLogRecord(
level: KcLogLevel.error,
source: 'Api',
message: 'Request failed',
timestamp: _fixedClock,
context: {'statusCode': 500},
error: const _ApiFailureStub(),
stackTrace: StackTrace.fromString('#0 main'),
);
final json = record.toJson();
expect(json['level'], 'ERROR');
expect(json['source'], 'Api');
expect(json['timestamp'], '2026-08-29T12:30:00.000Z');
expect((json['context']! as Map)['statusCode'], 500);
expect(json['error'], contains('ApiFailureStub'));
expect(json['stackTrace'], contains('#0 main'));
});
test('context is unmodifiable', () {
final record = KcLogRecord(
level: KcLogLevel.info,
source: 's',
message: 'm',
timestamp: _fixedClock,
context: {'a': 1},
);
expect(() => record.context['b'] = 2, throwsUnsupportedError);
});
});
// ── KcInMemoryLogSink ──────────────────────────────────────────────────────
group('KcInMemoryLogSink', () {
KcLogRecord recordAt(int i, {KcLogLevel level = KcLogLevel.info}) => KcLogRecord(
level: level,
source: 'test',
message: 'message $i',
timestamp: _fixedClock,
);
test('retains records in insertion order', () {
final sink = KcInMemoryLogSink()
..write(recordAt(1))
..write(recordAt(2));
expect(sink.records.map((r) => r.message), ['message 1', 'message 2']);
});
test('evicts oldest records beyond capacity', () {
final sink = KcInMemoryLogSink(capacity: 2)
..write(recordAt(1))
..write(recordAt(2))
..write(recordAt(3));
expect(sink.records.length, 2);
expect(sink.records.map((r) => r.message), ['message 2', 'message 3']);
});
test('recordsAtLeast filters by severity', () {
final sink = KcInMemoryLogSink()
..write(recordAt(1))
..write(recordAt(2, level: KcLogLevel.error));
expect(sink.recordsAtLeast(KcLogLevel.error).length, 1);
expect(sink.recordsAtLeast(KcLogLevel.error).single.message, 'message 2');
});
test('recent returns the trailing window', () {
final sink = KcInMemoryLogSink()
..write(recordAt(1))
..write(recordAt(2))
..write(recordAt(3));
expect(sink.recent(2).map((r) => r.message), ['message 2', 'message 3']);
expect(sink.recent(10).length, 3);
});
test('clear discards retained records', () {
final sink = KcInMemoryLogSink()..write(recordAt(1));
sink.clear();
expect(sink.records, isEmpty);
});
test('records list is unmodifiable', () {
final sink = KcInMemoryLogSink()..write(recordAt(1));
expect(() => sink.records.add(recordAt(2)), throwsUnsupportedError);
});
test('rejects non-positive capacity', () {
expect(() => KcInMemoryLogSink(capacity: 0), throwsA(isA<AssertionError>()));
});
});
// ── KcMultiLogSink ─────────────────────────────────────────────────────────
group('KcMultiLogSink', () {
test('fans out to every sink', () {
final a = KcInMemoryLogSink();
final b = KcInMemoryLogSink();
KcMultiLogSink([a, b]).write(
KcLogRecord(
level: KcLogLevel.info,
source: 's',
message: 'm',
timestamp: _fixedClock,
),
);
expect(a.records.length, 1);
expect(b.records.length, 1);
});
test('a throwing sink does not prevent delivery to the others', () {
final healthy = KcInMemoryLogSink();
KcMultiLogSink([_ThrowingLogSink(), healthy]).write(
KcLogRecord(
level: KcLogLevel.info,
source: 's',
message: 'm',
timestamp: _fixedClock,
),
);
expect(healthy.records.length, 1);
});
});
// ── KcLogger ───────────────────────────────────────────────────────────────
group('KcLogger', () {
late KcInMemoryLogSink sink;
late KcLogger logger;
setUp(() {
sink = KcInMemoryLogSink();
logger = KcLogger(
sink: sink,
minimumLevel: KcLogLevel.debug,
source: 'default',
clock: () => _fixedClock,
);
});
test('emits records at each severity helper', () {
logger
..debug('d')
..info('i')
..warning('w')
..error('e')
..fatal('f');
expect(sink.records.map((r) => r.level), [
KcLogLevel.debug,
KcLogLevel.info,
KcLogLevel.warning,
KcLogLevel.error,
KcLogLevel.fatal,
]);
});
test('drops records below the minimum level', () {
KcLogger(sink: sink, minimumLevel: KcLogLevel.warning, clock: () => _fixedClock)
..debug('dropped')
..info('dropped')
..warning('kept');
expect(sink.records.length, 1);
expect(sink.records.single.message, 'kept');
});
test('applies the default source and allows per-call override', () {
logger
..info('a')
..info('b', source: 'Override');
expect(sink.records[0].source, 'default');
expect(sink.records[1].source, 'Override');
});
test('redacts sensitive context before it reaches the sink', () {
logger.info('Configured', context: {'consumerSecret': 'cs_live', 'siteUrl': 'https://x'});
final context = sink.records.single.context;
expect(context['consumerSecret'], KcRedactor.mask);
expect(context['siteUrl'], 'https://x');
});
test('captures error and stack trace on failure records', () {
final trace = StackTrace.fromString('#0 failing');
logger.error('Boom', error: const _ApiFailureStub(), stackTrace: trace);
final record = sink.records.single;
expect(record.error, isA<_ApiFailureStub>());
expect(record.stackTrace, trace);
expect(record.isFailure, isTrue);
});
test('uses the injected clock for timestamps', () {
logger.info('m');
expect(sink.records.single.timestamp, _fixedClock);
});
test('never throws when the sink throws', () {
final brittle = KcLogger(sink: _ThrowingLogSink(), minimumLevel: KcLogLevel.debug);
expect(() => brittle.error('still safe'), returnsNormally);
});
test('forSource derives a child sharing sink and threshold', () {
final child = logger.forSource('SquareSync');
child.info('scoped');
expect(child.sink, same(sink));
expect(child.minimumLevel, KcLogLevel.debug);
expect(sink.records.single.source, 'SquareSync');
});
test('silent logger discards everything below fatal', () {
final silent = KcLogger.silent();
expect(silent.minimumLevel, KcLogLevel.fatal);
expect(() => silent.info('ignored'), returnsNormally);
});
});
// ── KcErrorReporter ────────────────────────────────────────────────────────
group('KcErrorReporter', () {
late KcInMemoryLogSink sink;
late KcLogger logger;
setUp(() {
sink = KcInMemoryLogSink();
logger = KcLogger(sink: sink, minimumLevel: KcLogLevel.debug, clock: () => _fixedClock);
});
tearDown(KcErrorReporter.restore);
test('install reports installation state', () {
expect(KcErrorReporter.isInstalled, isFalse);
KcErrorReporter.install(logger, forwardToOriginalHandler: false);
expect(KcErrorReporter.isInstalled, isTrue);
KcErrorReporter.restore();
expect(KcErrorReporter.isInstalled, isFalse);
});
test('captures uncaught Flutter framework errors as error records', () {
KcErrorReporter.install(logger, forwardToOriginalHandler: false);
FlutterError.onError!(
FlutterErrorDetails(
exception: const _ApiFailureStub(),
stack: StackTrace.fromString('#0 build'),
library: 'widgets library',
),
);
final record = sink.records.single;
expect(record.level, KcLogLevel.error);
expect(record.source, KcErrorReporter.source);
expect(record.error, isA<_ApiFailureStub>());
expect(record.context['library'], 'widgets library');
});
test('forwards to the original handler when requested', () {
var forwarded = false;
final original = FlutterError.onError;
FlutterError.onError = (_) => forwarded = true;
KcErrorReporter.install(logger);
FlutterError.onError!(FlutterErrorDetails(exception: const _ApiFailureStub()));
expect(forwarded, isTrue);
expect(sink.records.single.level, KcLogLevel.error);
KcErrorReporter.restore();
FlutterError.onError = original;
});
test('runGuarded records uncaught zone errors as fatal', () async {
KcErrorReporter.runGuarded(logger, () {
Future<void>.error(const _ApiFailureStub());
});
await Future<void>.delayed(Duration.zero);
expect(sink.records.single.level, KcLogLevel.fatal);
expect(sink.records.single.message, 'Uncaught zone error');
});
test('runGuarded returns the body result when nothing throws', () {
final result = KcErrorReporter.runGuarded<int>(logger, () => 7);
expect(result, 7);
expect(sink.records, isEmpty);
});
test('restore is safe to call when not installed', () {
expect(KcErrorReporter.restore, returnsNormally);
});
});
// ── KcBugSeverity ──────────────────────────────────────────────────────────
group('KcBugSeverity', () {
test('exposes display labels', () {
expect(KcBugSeverity.low.label, 'Low');
expect(KcBugSeverity.critical.label, 'Critical');
});
test('fromString parses known values and defaults to medium', () {
expect(KcBugSeverity.fromString('high'), KcBugSeverity.high);
expect(KcBugSeverity.fromString('unknown'), KcBugSeverity.medium);
});
});
// ── KcBugReport ────────────────────────────────────────────────────────────
group('KcBugReport', () {
KcBugReport buildReport({
List<KcLogRecord> logTail = const [],
Map<String, Object?> diagnostics = const {},
}) => KcBugReport(
id: 'KC-BUG-1',
title: 'Checkout fails',
description: 'Tapping Card does nothing.',
severity: KcBugSeverity.high,
createdAt: _fixedClock,
appVersion: '1.0.0+1',
environment: 'SQ',
platform: 'android',
reporter: 'operator@example.com',
screen: 'MobileCheckoutPage',
logTail: logTail,
diagnostics: diagnostics,
);
test('redacts diagnostics on construction', () {
final report = buildReport(diagnostics: {'accessToken': 'EAAAsecret', 'cartSize': 2});
expect(report.diagnostics['accessToken'], KcRedactor.mask);
expect(report.diagnostics['cartSize'], 2);
});
test('hasFailureEvidence reflects attached failure records', () {
final withFailure = buildReport(
logTail: [
KcLogRecord(
level: KcLogLevel.error,
source: 's',
message: 'failed',
timestamp: _fixedClock,
),
],
);
final withoutFailure = buildReport(
logTail: [
KcLogRecord(level: KcLogLevel.info, source: 's', message: 'ok', timestamp: _fixedClock),
],
);
expect(withFailure.hasFailureEvidence, isTrue);
expect(withoutFailure.hasFailureEvidence, isFalse);
});
test('toJson carries metadata and the serialised log tail', () {
final report = buildReport(
logTail: [
KcLogRecord(
level: KcLogLevel.error,
source: 'Checkout',
message: 'payment failed',
timestamp: _fixedClock,
),
],
);
final json = report.toJson();
expect(json['id'], 'KC-BUG-1');
expect(json['severity'], 'high');
expect(json['appVersion'], '1.0.0+1');
expect(json['environment'], 'SQ');
expect(json['platform'], 'android');
expect(json['screen'], 'MobileCheckoutPage');
expect((json['logTail']! as List).length, 1);
});
test('toMarkdown renders the report with evidence', () {
final report = buildReport(
diagnostics: {'cartSize': 2},
logTail: [
KcLogRecord(
level: KcLogLevel.error,
source: 'Checkout',
message: 'payment failed',
timestamp: _fixedClock,
),
],
);
final markdown = report.toMarkdown();
expect(markdown, contains('# Bug Report: Checkout fails'));
expect(markdown, contains('**Severity:** High'));
expect(markdown, contains('**Environment:** SQ'));
expect(markdown, contains('## Diagnostics'));
expect(markdown, contains('cartSize'));
expect(markdown, contains('## Recent log records'));
expect(markdown, contains('payment failed'));
});
test('logTail is unmodifiable', () {
final report = buildReport();
expect(
() => report.logTail.add(
KcLogRecord(level: KcLogLevel.info, source: 's', message: 'm', timestamp: _fixedClock),
),
throwsUnsupportedError,
);
});
});
// ── KcBugReportService ─────────────────────────────────────────────────────
group('KcBugReportService', () {
late KcInMemoryLogSink logSink;
late KcInMemoryBugReportSink reportSink;
late KcLogger logger;
late KcBugReportService service;
setUp(() {
logSink = KcInMemoryLogSink();
reportSink = KcInMemoryBugReportSink();
logger = KcLogger(sink: logSink, minimumLevel: KcLogLevel.debug, clock: () => _fixedClock);
service = KcBugReportService(
sink: reportSink,
logger: logger,
logSink: logSink,
appVersion: '1.0.0+1',
environment: 'FAKE',
platform: 'test',
clock: () => _fixedClock,
idGenerator: () => 'KC-BUG-FIXED',
);
});
test('submits a valid report and stamps build metadata', () async {
final result = await service.submit(
title: 'Sync stalls',
description: 'Square sync never completes.',
severity: KcBugSeverity.high,
);
expect(result.succeeded, isTrue);
expect(result.report!.id, 'KC-BUG-FIXED');
expect(result.report!.appVersion, '1.0.0+1');
expect(result.report!.environment, 'FAKE');
expect(result.report!.platform, 'test');
expect(reportSink.reports.length, 1);
});
test('attaches the recent log tail as evidence', () async {
logger.error('Square sync failed', context: {'stage': 'catalog'});
final result = await service.submit(title: 'Sync stalls', description: 'Details here.');
expect(result.report!.logTail, isNotEmpty);
expect(result.report!.hasFailureEvidence, isTrue);
});
test('rejects a blank title', () async {
final result = await service.submit(title: ' ', description: 'Details here.');
expect(result.succeeded, isFalse);
expect(result.errorMessage, 'A title is required.');
expect(reportSink.reports, isEmpty);
});
test('rejects a blank description', () async {
final result = await service.submit(title: 'Title', description: ' ');
expect(result.succeeded, isFalse);
expect(result.errorMessage, 'A description is required.');
expect(reportSink.reports, isEmpty);
});
test('trims whitespace from title and description', () async {
final result = await service.submit(title: ' Sync stalls ', description: ' Details. ');
expect(result.report!.title, 'Sync stalls');
expect(result.report!.description, 'Details.');
});
test('redacts diagnostics supplied by the caller', () async {
final result = await service.submit(
title: 'Auth issue',
description: 'Cannot connect.',
diagnostics: {'consumerSecret': 'cs_live_abc', 'endpoint': '/wp-json'},
);
expect(result.report!.diagnostics['consumerSecret'], KcRedactor.mask);
expect(result.report!.diagnostics['endpoint'], '/wp-json');
});
test('logs a record when submission succeeds', () async {
await service.submit(title: 'T', description: 'D');
final submitted = logSink.records.where((r) => r.message == 'Bug report submitted');
expect(submitted, hasLength(1));
expect(submitted.single.context['reportId'], 'KC-BUG-FIXED');
});
test('logs critical reports at error severity', () async {
await service.submit(title: 'T', description: 'D', severity: KcBugSeverity.critical);
final submitted = logSink.records.firstWhere((r) => r.message == 'Bug report submitted');
expect(submitted.level, KcLogLevel.error);
});
test('returns a failure result and logs when the sink throws', () async {
final failing = KcBugReportService(
sink: _FailingBugReportSink(),
logger: logger,
appVersion: '1.0.0+1',
environment: 'FAKE',
platform: 'test',
clock: () => _fixedClock,
idGenerator: () => 'KC-BUG-FIXED',
);
final result = await failing.submit(title: 'T', description: 'D');
expect(result.succeeded, isFalse);
expect(result.errorMessage, contains('Could not submit the report'));
expect(logSink.records.any((r) => r.message == 'Bug report submission failed'), isTrue);
});
test('buildReport does not submit', () {
final report = service.buildReport(title: 'T', description: 'D');
expect(report.id, 'KC-BUG-FIXED');
expect(reportSink.reports, isEmpty);
});
test('generates unique ids when no id generator is supplied', () async {
final auto = KcBugReportService(
sink: reportSink,
logger: logger,
appVersion: '1.0.0+1',
environment: 'FAKE',
platform: 'test',
clock: () => _fixedClock,
);
final first = await auto.submit(title: 'A', description: 'D');
final second = await auto.submit(title: 'B', description: 'D');
expect(first.report!.id, isNot(second.report!.id));
});
test('in-memory sink retains submissions and can be cleared', () async {
await service.submit(title: 'T', description: 'D');
expect(reportSink.reports, hasLength(1));
reportSink.clear();
expect(reportSink.reports, isEmpty);
});
test('result toString reflects success and failure', () async {
final ok = await service.submit(title: 'T', description: 'D');
const bad = KcBugReportResult.failure('nope');
expect(ok.toString(), contains('success'));
expect(bad.toString(), contains('failure'));
});
});
}

View File

@ -10,6 +10,7 @@ export 'src/theme/kc_typography.dart';
export 'src/layout/kc_breakpoints.dart'; export 'src/layout/kc_breakpoints.dart';
// Widgets // Widgets
export 'src/widgets/kc_bug_report_dialog.dart';
export 'src/widgets/kc_card.dart'; export 'src/widgets/kc_card.dart';
export 'src/widgets/kc_empty_state.dart'; export 'src/widgets/kc_empty_state.dart';
export 'src/widgets/kc_error_state.dart'; export 'src/widgets/kc_error_state.dart';

View File

@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import '../theme/kc_spacing.dart';
/// The values captured by [KcBugReportDialog] when the operator submits.
///
/// The dialog is deliberately decoupled from `core` so `design_system` stays
/// dependency-free. The caller maps [severityName] onto `KcBugSeverity` and
/// forwards the values to `KcBugReportService.submit`.
class KcBugReportDraft {
/// Short summary of the defect.
final String title;
/// Detailed description and reproduction steps.
final String description;
/// Selected severity, matching the `KcBugSeverity` enum names
/// (`low`, `medium`, `high`, `critical`).
final String severityName;
/// Optional reporter identity.
final String? reporter;
const KcBugReportDraft({
required this.title,
required this.description,
required this.severityName,
this.reporter,
});
@override
String toString() => 'KcBugReportDraft($severityName: $title)';
}
/// A shared bug-reporting dialog used by both the web and mobile shells.
///
/// Collects a title, description, severity, and optional reporter identity.
/// Validation is enforced inline before the dialog can be submitted, so the
/// caller only ever receives a well-formed [KcBugReportDraft].
///
/// Returns the draft on submit, or `null` when cancelled.
///
/// ```dart
/// final draft = await KcBugReportDialog.show(context);
/// if (draft != null) {
/// await service.submit(
/// title: draft.title,
/// description: draft.description,
/// severity: KcBugSeverity.fromString(draft.severityName),
/// );
/// }
/// ```
class KcBugReportDialog extends StatefulWidget {
/// The screen or workflow the operator is reporting from, shown as context.
final String? screen;
/// Severity options offered, as `(name, label)` pairs.
final List<({String name, String label})> severities;
const KcBugReportDialog({
super.key,
this.screen,
this.severities = const [
(name: 'low', label: 'Low'),
(name: 'medium', label: 'Medium'),
(name: 'high', label: 'High'),
(name: 'critical', label: 'Critical'),
],
});
/// Shows the dialog and resolves to the submitted draft, or `null` if the
/// operator cancelled.
static Future<KcBugReportDraft?> show(BuildContext context, {String? screen}) {
return showDialog<KcBugReportDraft>(
context: context,
builder: (_) => KcBugReportDialog(screen: screen),
);
}
@override
State<KcBugReportDialog> createState() => _KcBugReportDialogState();
}
class _KcBugReportDialogState extends State<KcBugReportDialog> {
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _reporterController = TextEditingController();
late String _severityName = widget.severities.length > 1
? widget.severities[1].name
: widget.severities.first.name;
String? _titleError;
String? _descriptionError;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_reporterController.dispose();
super.dispose();
}
void _submit() {
final title = _titleController.text.trim();
final description = _descriptionController.text.trim();
setState(() {
_titleError = title.isEmpty ? 'A title is required.' : null;
_descriptionError = description.isEmpty ? 'A description is required.' : null;
});
if (title.isEmpty || description.isEmpty) return;
final reporter = _reporterController.text.trim();
Navigator.of(context).pop(
KcBugReportDraft(
title: title,
description: description,
severityName: _severityName,
reporter: reporter.isEmpty ? null : reporter,
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Report a problem'),
content: SizedBox(
width: 420,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.screen != null) ...[
Text('Screen: ${widget.screen}', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: KcSpacing.sm),
],
TextField(
controller: _titleController,
autofocus: true,
decoration: InputDecoration(
labelText: 'What went wrong?',
hintText: 'Short summary',
errorText: _titleError,
),
),
const SizedBox(height: KcSpacing.md),
TextField(
controller: _descriptionController,
minLines: 3,
maxLines: 6,
decoration: InputDecoration(
labelText: 'Details and steps to reproduce',
errorText: _descriptionError,
),
),
const SizedBox(height: KcSpacing.md),
DropdownButtonFormField<String>(
initialValue: _severityName,
decoration: const InputDecoration(labelText: 'Severity'),
items: [
for (final severity in widget.severities)
DropdownMenuItem(value: severity.name, child: Text(severity.label)),
],
onChanged: (value) {
if (value != null) setState(() => _severityName = value);
},
),
const SizedBox(height: KcSpacing.md),
TextField(
controller: _reporterController,
decoration: const InputDecoration(labelText: 'Your name or email (optional)'),
),
const SizedBox(height: KcSpacing.md),
Text(
'Recent activity logs are attached automatically. '
'Credentials are never included.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(onPressed: _submit, child: const Text('Submit report')),
],
);
}
}

View File

@ -0,0 +1,168 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Pumps a host exposing a button that opens [KcBugReportDialog].
///
/// [onResult] receives the resolved draft (or `null` when cancelled).
Future<void> _pumpHost(
WidgetTester tester, {
String? screen,
void Function(KcBugReportDraft?)? onResult,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
final draft = await KcBugReportDialog.show(context, screen: screen);
onResult?.call(draft);
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
/// Fills the required fields with valid values.
Future<void> _fillRequired(WidgetTester tester, {String title = 'Title', String body = 'Body'}) async {
await tester.enterText(find.byType(TextField).at(0), title);
await tester.enterText(find.byType(TextField).at(1), body);
}
void main() {
group('KcBugReportDraft', () {
test('holds the captured values', () {
const draft = KcBugReportDraft(
title: 'Checkout fails',
description: 'Card button does nothing',
severityName: 'high',
reporter: 'op@example.com',
);
expect(draft.title, 'Checkout fails');
expect(draft.description, 'Card button does nothing');
expect(draft.severityName, 'high');
expect(draft.reporter, 'op@example.com');
expect(draft.toString(), contains('high'));
});
});
group('KcBugReportDialog', () {
testWidgets('renders the reporting form', (tester) async {
await _pumpHost(tester);
expect(find.text('Report a problem'), findsOneWidget);
expect(find.text('What went wrong?'), findsOneWidget);
expect(find.text('Details and steps to reproduce'), findsOneWidget);
expect(find.text('Severity'), findsOneWidget);
expect(find.text('Submit report'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
testWidgets('shows the originating screen when supplied', (tester) async {
await _pumpHost(tester, screen: 'MobileCheckoutPage');
expect(find.text('Screen: MobileCheckoutPage'), findsOneWidget);
});
testWidgets('states that logs are attached and credentials excluded', (tester) async {
await _pumpHost(tester);
expect(find.textContaining('Recent activity logs are attached'), findsOneWidget);
expect(find.textContaining('Credentials are never included'), findsOneWidget);
});
testWidgets('blocks submission and shows errors when fields are blank', (tester) async {
await _pumpHost(tester);
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(find.text('A title is required.'), findsOneWidget);
expect(find.text('A description is required.'), findsOneWidget);
expect(find.text('Report a problem'), findsOneWidget);
});
testWidgets('rejects whitespace-only input', (tester) async {
await _pumpHost(tester);
await _fillRequired(tester, title: ' ', body: ' ');
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(find.text('A title is required.'), findsOneWidget);
expect(find.text('A description is required.'), findsOneWidget);
});
testWidgets('returns a trimmed draft on submit', (tester) async {
KcBugReportDraft? captured;
await _pumpHost(tester, onResult: (d) => captured = d);
await _fillRequired(tester, title: ' Sync stalls ', body: ' Never completes. ');
await tester.enterText(find.byType(TextField).at(2), ' op@example.com ');
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(captured, isNotNull);
expect(captured!.title, 'Sync stalls');
expect(captured!.description, 'Never completes.');
expect(captured!.reporter, 'op@example.com');
expect(captured!.severityName, 'medium');
});
testWidgets('omits an empty reporter', (tester) async {
KcBugReportDraft? captured;
await _pumpHost(tester, onResult: (d) => captured = d);
await _fillRequired(tester);
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(captured!.reporter, isNull);
});
testWidgets('captures the selected severity', (tester) async {
KcBugReportDraft? captured;
await _pumpHost(tester, onResult: (d) => captured = d);
await _fillRequired(tester);
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Critical').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Submit report'));
await tester.pumpAndSettle();
expect(captured!.severityName, 'critical');
});
testWidgets('cancel resolves to null', (tester) async {
var resolved = false;
KcBugReportDraft? captured;
await _pumpHost(
tester,
onResult: (d) {
captured = d;
resolved = true;
},
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(resolved, isTrue);
expect(captured, isNull);
expect(find.text('Report a problem'), findsNothing);
});
});
}

View File

@ -68,6 +68,7 @@ nav:
- Operations Overview: operations/index.md - Operations Overview: operations/index.md
- CI/CD Workflow: operations/cicd-workflow.md - CI/CD Workflow: operations/cicd-workflow.md
- Architecture Workflow: operations/architecture-workflow.md - Architecture Workflow: operations/architecture-workflow.md
- Release Checklist: operations/release-checklist.md
- Standards: - Standards:
- Standards Overview: "standards/index.md" - Standards Overview: "standards/index.md"
- Integrations: - Integrations: