Merge feat/custom-order-ui: Stage 11B custom order intake UI (2044/2044 tests passing)
Publish Docs / publish-docs (push) Successful in 53s
Details
Publish Docs / publish-docs (push) Successful in 53s
Details
This commit is contained in:
commit
6c299e8261
|
|
@ -242,7 +242,7 @@ kell_creations/
|
||||||
| `CustomOrderSpec` value object | ❌ Missing (Stage 11A) |
|
| `CustomOrderSpec` value object | ❌ Missing (Stage 11A) |
|
||||||
| `DepositInfo` value object | ❌ Missing (Stage 11A) |
|
| `DepositInfo` value object | ❌ Missing (Stage 11A) |
|
||||||
| Custom order fulfillment status pipeline | ❌ Missing (Stage 11C) |
|
| Custom order fulfillment status pipeline | ❌ Missing (Stage 11C) |
|
||||||
| `CustomOrderFormPage` | ❌ Missing (Stage 11B) |
|
| `CustomOrderFormPage` | ✅ Delivered (11B) |
|
||||||
| `CustomOrderTrackingPage` | ❌ Missing (Stage 11C) |
|
| `CustomOrderTrackingPage` | ❌ Missing (Stage 11C) |
|
||||||
| `OrderChannel` enum (woocommerce, square, manual) | ❌ Missing (Stage 13B) |
|
| `OrderChannel` enum (woocommerce, square, manual) | ❌ Missing (Stage 13B) |
|
||||||
| `UnifiedOrdersController` (multi-channel aggregation) | ❌ Missing (Stage 13B) |
|
| `UnifiedOrdersController` (multi-channel aggregation) | ❌ Missing (Stage 13B) |
|
||||||
|
|
@ -520,11 +520,14 @@ These items complete planned stages and wire existing contracts into production
|
||||||
#### P2.2 — Custom Orders Workflow (Stage 11) — ⚠️ PARTIALLY RESOLVED
|
#### P2.2 — Custom Orders Workflow (Stage 11) — ⚠️ PARTIALLY RESOLVED
|
||||||
|
|
||||||
> Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
|
> Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
|
||||||
|
> Intake UI delivered on `feat/custom-order-ui` (Stage 11B, 2026-09-25).
|
||||||
|
|
||||||
**Delivered:** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object (money as integer cents), `FulfillmentStatus` pipeline with domain-enforced legal transitions (`quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled` and a `qualityCheck → inProduction` rework path), extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` / `advanceFulfillmentStatus()` on `OrdersRepository`, implemented in both `FakeOrdersRepository` (seeded across every stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data). 423 new tests.
|
**Delivered (11A):** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object (money as integer cents), `FulfillmentStatus` pipeline with domain-enforced legal transitions (`quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled` and a `qualityCheck → inProduction` rework path), extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` / `advanceFulfillmentStatus()` on `OrdersRepository`, implemented in both `FakeOrdersRepository` (seeded across every stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data). 423 new tests.
|
||||||
|
|
||||||
**Still open:** `CustomOrderFormPage`, `CustomOrderConfirmationPage` (Stage 11B); `CustomOrderTrackingPage`, status-change timeline, and inventory deduction on completion (Stage 11C).
|
**Delivered (11B):** `CreateCustomOrder` / `RecordDeposit` use cases, `CustomOrderFormController` (field-keyed validation, integer-cent money parsing, injectable clock and ID generator), `CustomOrderFormPage`, `CustomOrderConfirmationPage` with a deposit receipt that carries no payment identifier, `FulfillmentStatusChip`, `showReferenceImageDialog()`, an optional `onCreateCustomOrder` action on `OrdersPage`, the `/orders/custom` web route, and `MobileCustomOrderPage` behind an orders FAB. 146 new tests.
|
||||||
**Dependencies:** Stage 9A ✅, Stage 11A ✅
|
|
||||||
|
**Still open:** `CustomOrderTrackingPage`, the status-change timeline, catalogue product-base selection, platform camera / file-picker image capture, and inventory deduction on completion (Stage 11C).
|
||||||
|
**Dependencies:** Stage 9A ✅, Stage 11A ✅, Stage 11B ✅
|
||||||
|
|
||||||
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
|
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
|
||||||
|
|
||||||
|
|
@ -664,7 +667,7 @@ These items harden the platform for production deployment.
|
||||||
| **Square catalog sync** | ✅ Service + controller + UI panel; ID bug fixed (10E/10F) |
|
| **Square catalog sync** | ✅ Service + controller + UI panel; ID bug fixed (10E/10F) |
|
||||||
| **Square inventory sync** | ✅ Repository wired via `square()` / `multi()` factories |
|
| **Square inventory sync** | ✅ Repository wired via `square()` / `multi()` factories |
|
||||||
| **Square payments / POS** | ✅ Mobile checkout flow implemented (Stage 10C) |
|
| **Square payments / POS** | ✅ Mobile checkout flow implemented (Stage 10C) |
|
||||||
| **Custom orders** | ⚠️ Domain + data done (Stage 11A); UI open (Stage 11B) |
|
| **Custom orders** | ⚠️ Domain, data, and intake UI done (11A/11B); tracking open |
|
||||||
| **Observability / error logging** | ✅ `KcLogger`, `KcErrorReporter`, bug reports (Stage 16A) |
|
| **Observability / error logging** | ✅ `KcLogger`, `KcErrorReporter`, bug reports (Stage 16A) |
|
||||||
| **Supplies tracking** | ❌ Not implemented |
|
| **Supplies tracking** | ❌ Not implemented |
|
||||||
| **Multi-channel sync engine** | ❌ Not implemented |
|
| **Multi-channel sync engine** | ❌ Not implemented |
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,58 @@
|
||||||
- merged: `feat/custom-order-domain` (Stage 11A) — custom order domain and data; merged to main 2026-08-29, commit `85501fd`
|
- merged: `feat/custom-order-domain` (Stage 11A) — custom order domain and data; merged to main 2026-08-29, commit `85501fd`
|
||||||
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29)
|
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29)
|
||||||
- reconciled: `chore/state-reconciliation-flutter-347` — working tree vs. `main` reconciled 2026-09-25; toolchain moved to Flutter 3.47.0 / Dart 3.13.0; master documents re-baselined
|
- reconciled: `chore/state-reconciliation-flutter-347` — working tree vs. `main` reconciled 2026-09-25; toolchain moved to Flutter 3.47.0 / Dart 3.13.0; master documents re-baselined
|
||||||
- next branch: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI
|
- in progress: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI; complete, pending merge
|
||||||
- total tests on branch: 1898/1898 passing (+423 new, all in feature_orders); dart analyze --fatal-infos clean across all 11 packages and apps
|
- next branch: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment
|
||||||
|
- total tests on branch: 2044/2044 passing (+146 new: 135 in feature_orders, 6 in kell_mobile, 5 in kell_web); dart analyze --fatal-infos clean across all 11 packages and apps
|
||||||
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
|
- 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
|
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
|
||||||
- toolchain: Flutter 3.47.0 / Dart 3.13.0. Flutter 3.47 auto-generates an `analyzer.exclude` block in every `analysis_options.yaml` on each `pub get` / `analyze` / `run` / `build` with no opt-out ([flutter#191160](https://github.com/flutter/flutter/issues/191160)). **Do not open a slice to revert it** — see "Toolchain baseline" in `master_development_brief.md`
|
- toolchain: Flutter 3.47.0 / Dart 3.13.0. Flutter 3.47 auto-generates an `analyzer.exclude` block in every `analysis_options.yaml` on each `pub get` / `analyze` / `run` / `build` with no opt-out ([flutter#191160](https://github.com/flutter/flutter/issues/191160)). **Do not open a slice to revert it** — see "Toolchain baseline" in `master_development_brief.md`
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
### feat/custom-order-ui
|
||||||
|
|
||||||
|
- status: complete, pending merge
|
||||||
|
- branch: `feat/custom-order-ui`
|
||||||
|
- stage: 11B — Custom order creation UI
|
||||||
|
- date: 2026-09-25
|
||||||
|
- baseline: `0e3987b` (main, Stage 11A complete + state reconciliation)
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- closes audit findings: P2.2 (UI portion — intake and confirmation; tracking remains open as Stage 11C)
|
||||||
|
- scope: application and presentation layers plus app wiring — **no domain or data change**; every repository contract from Stage 11A is consumed as-is
|
||||||
|
- files added — `feature_orders` application:
|
||||||
|
- `lib/src/application/create_custom_order.dart` — `CreateCustomOrder` use case wrapping `OrdersRepository.createCustomOrder`
|
||||||
|
- `lib/src/application/record_deposit.dart` — `RecordDeposit` use case wrapping `OrdersRepository.recordDeposit`; accepts **no** payment identifier
|
||||||
|
- `lib/src/application/custom_order_form_controller.dart` — `CustomOrderField` enum + `CustomOrderFormController` (`ChangeNotifier`): field state, field-keyed validation, `buildSpec()`, `submit()`, `reset()`, derived `estimatedPriceCents` / `depositAmountCents` / `balanceDueCents`, and the static `parseMoneyToCents()` money parser. Injectable clock and ID generator keep tests deterministic
|
||||||
|
- files added — `feature_orders` presentation:
|
||||||
|
- `lib/src/presentation/custom_order_form_page.dart` — `CustomOrderFormPage`; owns a controller unless one is injected, swaps to the confirmation page on success
|
||||||
|
- `lib/src/presentation/custom_order_confirmation_page.dart` — `CustomOrderConfirmationPage` with the commission summary and the deposit receipt
|
||||||
|
- `lib/src/presentation/widgets/fulfillment_status_chip.dart` — `FulfillmentStatusChip` over the design-system `KcStatusChip`
|
||||||
|
- `lib/src/presentation/widgets/reference_image_dialog.dart` — `showReferenceImageDialog()` + `ReferenceImageDialogResult` with URL validation
|
||||||
|
- files changed:
|
||||||
|
- `feature_orders/lib/src/presentation/orders_page.dart` — optional `onCreateCustomOrder` callback surfaces a "New Custom Order" toolbar action; omitted when the host has no intake route
|
||||||
|
- `feature_orders/lib/feature_orders.dart` — barrel exports for all new application and presentation types
|
||||||
|
- `feature_orders/README.md` — documents the intake UI and the cents-only money rule
|
||||||
|
- `kell_web/lib/routing/app_routes.dart` — new `/orders/custom` route rendering `CustomOrderFormPage` inside `AppShell`; orders route now passes `onCreateCustomOrder`
|
||||||
|
- `kell_web/lib/navigation/app_navigation.dart` — `ordersToCustomOrder()` and `customOrderToOrders()` handoffs
|
||||||
|
- `kell_mobile/lib/pages/mobile_custom_order_page.dart` — **new** full-screen `SafeArea` host for the shared form
|
||||||
|
- `kell_mobile/lib/pages/mobile_orders_page.dart` — extended FAB pushes the intake form and reloads the list on return
|
||||||
|
- tests added (146 new):
|
||||||
|
- `feature_orders/test/custom_order_form_controller_test.dart` — 82 tests (money parsing incl. separators/precision/negatives, initial state, setters, derived balance and clamping, reference image add/remove/dedupe, the full validation matrix, `buildSpec` mapping, submission, deposit quoting vs. collection, repository failure, duplicate ID, reset)
|
||||||
|
- `feature_orders/test/widgets/custom_order_form_page_test.dart` — 26 widget tests (section and field rendering, validation feedback per field, running balance, deposit method reveal, reference image dialog paths, submission → confirmation, "New Custom Order" reset, `onComplete`)
|
||||||
|
- `feature_orders/test/widgets/custom_order_confirmation_page_test.dart` — 24 widget tests (summary, spec rendering, deposit receipt quoted vs. paid, balance, **no payment identifier on screen**, actions, the 7-value `FulfillmentStatusChip` matrix)
|
||||||
|
- `feature_orders/test/widgets/orders_page_test.dart` — 3 new tests for the custom order entry point
|
||||||
|
- `kell_mobile/test/pages/mobile_custom_order_page_test.dart` — 6 tests (app bar, shared form host, `SafeArea`, sections, submit action, validation)
|
||||||
|
- `kell_web/test/routing/custom_order_route_test.dart` — 5 tests (route constant, route renders the form in the shell, intake sections, orders action present, action opens the form)
|
||||||
|
- privacy: the deposit receipt renders amount, method, date, and balance only — the transaction ID stays on `PaymentResult` and is asserted absent by test. The controller holds no payment identifier at any point
|
||||||
|
- money: every amount is typed as text and converted to **integer cents** by `parseMoneyToCents` before it reaches `DepositInfo`; the only `double` is the derived `OrderItem.unitPrice`, computed once from cents
|
||||||
|
- test-harness note: the intake form is taller than the default 800×600 test view, so the submit-button tests resize `tester.view` rather than working around a layout defect — the page scrolls normally on device
|
||||||
|
- toolchain note: `kell_web/pubspec.lock` picked up SDK-pinned transitive bumps on `flutter pub get` (`matcher` 0.12.19→0.12.20, `meta` 1.18.0→1.19.0, `test_api` 0.7.11→0.7.12, `vector_math` 2.2.0→2.4.3). Same accepted class as the `kell_mobile` bumps recorded under `chore/state-reconciliation-flutter-347`; committed so the lock file matches the toolchain in use
|
||||||
|
- tests: passed (792/792 feature_orders, 87/87 kell_mobile, 67/67 kell_web, 545/545 feature_wordpress, 167/167 feature_inventory, 123/123 integrations, 90/90 core, 63/63 data, 51/51 design_system, 42/42 auth, 17/17 feature_policy — **2044 total**)
|
||||||
|
- analyze: passed (`dart analyze --fatal-infos` — no issues found across all 11 packages and apps)
|
||||||
|
- brief updated: yes
|
||||||
|
|
||||||
### chore/state-reconciliation-flutter-347
|
### chore/state-reconciliation-flutter-347
|
||||||
|
|
||||||
- status: merged to main
|
- status: merged to main
|
||||||
|
|
|
||||||
|
|
@ -143,24 +143,25 @@ Rules:
|
||||||
|
|
||||||
### Latest known validation state on `main`
|
### Latest known validation state on `main`
|
||||||
|
|
||||||
> **Last verified:** 2026-08-29 — Stage 11A validation run (`feat/custom-order-domain`)
|
> **Last verified:** 2026-09-25 — Stage 11B validation run (`feat/custom-order-ui`)
|
||||||
|
|
||||||
- `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`: `90/90 passed`
|
- latest reported count for `core`: `90/90 passed`
|
||||||
- latest reported count for `design_system`: `51/51 passed`
|
- latest reported count for `design_system`: `51/51 passed`
|
||||||
- latest reported count for `feature_inventory`: `167/167 passed`
|
- latest reported count for `feature_inventory`: `167/167 passed`
|
||||||
- latest reported count for `feature_orders`: `657/657 passed` ← +423 new (Stage 11A custom orders)
|
- latest reported count for `feature_orders`: `792/792 passed` ← +135 new (Stage 11B custom order UI)
|
||||||
- latest reported count for `feature_wordpress`: `545/545 passed`
|
- latest reported count for `feature_wordpress`: `545/545 passed`
|
||||||
- latest reported count for `kell_web`: `62/62 passed`
|
- latest reported count for `kell_web`: `67/67 passed` ← +5 new (custom order route)
|
||||||
- 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`: `81/81 passed`
|
- latest reported count for `kell_mobile`: `87/87 passed` ← +6 new (`MobileCustomOrderPage`)
|
||||||
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
|
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
|
||||||
- total: `1,898/1,898 passed` (+423 new from Stage 11A branch `feat/custom-order-domain`)
|
- total: `2,044/2,044 passed` (+146 new from Stage 11B branch `feat/custom-order-ui`)
|
||||||
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29, commit `85501fd`)
|
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29, commit `85501fd`)
|
||||||
- next branch: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI
|
- current branch work: `feat/custom-order-ui` (Stage 11B) — complete, pending merge
|
||||||
|
- next branch: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
|
|
||||||
|
|
@ -350,15 +351,70 @@ Validation:
|
||||||
- 1,898 total across all packages; no regression from the 1,475 baseline
|
- 1,898 total across all packages; no regression from the 1,475 baseline
|
||||||
- `dart analyze --fatal-infos` clean across all 11 packages and apps
|
- `dart analyze --fatal-infos` clean across all 11 packages and apps
|
||||||
|
|
||||||
|
### Stage 11B — complete (2026-09-25)
|
||||||
|
|
||||||
|
**`feat/custom-order-ui`** — Custom order creation UI
|
||||||
|
|
||||||
|
Closes the UI half of audit finding P2.2. Application and presentation layers
|
||||||
|
plus app wiring only — **no domain or data change**; every repository contract
|
||||||
|
delivered in Stage 11A is consumed as-is.
|
||||||
|
|
||||||
|
Delivered in `feature_orders` — application:
|
||||||
|
|
||||||
|
- `CreateCustomOrder` and `RecordDeposit` use cases. `RecordDeposit` accepts
|
||||||
|
**no** payment identifier, matching the repository contract
|
||||||
|
- `CustomOrderFormController` (`ChangeNotifier`) — field state, field-keyed
|
||||||
|
validation via the `CustomOrderField` enum, `buildSpec()`, `submit()`,
|
||||||
|
`reset()`, and derived `estimatedPriceCents` / `depositAmountCents` /
|
||||||
|
`balanceDueCents` (clamped at zero). The clock and the ID generator are
|
||||||
|
injectable so tests are deterministic
|
||||||
|
- `CustomOrderFormController.parseMoneyToCents()` — typed money (`'$1,299.5'`)
|
||||||
|
becomes **integer cents** before it reaches the domain. No `double`
|
||||||
|
arithmetic ever decides a price
|
||||||
|
|
||||||
|
Delivered in `feature_orders` — presentation:
|
||||||
|
|
||||||
|
- `CustomOrderFormPage` — customer capture, description, spec fields, reference
|
||||||
|
images, pricing with a live balance, deposit collection with a method
|
||||||
|
selector, estimated completion picker, and intake source. Owns a controller
|
||||||
|
unless one is injected, and swaps to the confirmation page on success
|
||||||
|
- `CustomOrderConfirmationPage` — commission summary plus a deposit receipt
|
||||||
|
showing amount, method, date, and balance. **No payment identifier** appears
|
||||||
|
on screen; that stays on `PaymentResult`
|
||||||
|
- `FulfillmentStatusChip` over the design-system `KcStatusChip`, and
|
||||||
|
`showReferenceImageDialog()` with URL validation
|
||||||
|
- `OrdersPage` gains an optional `onCreateCustomOrder` callback; the action is
|
||||||
|
omitted entirely when the host has no intake route
|
||||||
|
|
||||||
|
Wired into both app targets:
|
||||||
|
|
||||||
|
- `kell_web` — new `/orders/custom` route inside `AppShell`, plus
|
||||||
|
`AppNavigation.ordersToCustomOrder()` / `customOrderToOrders()` handoffs
|
||||||
|
- `kell_mobile` — `MobileCustomOrderPage` hosts the shared form in a
|
||||||
|
`SafeArea`; `MobileOrdersPage` pushes it from an extended FAB and reloads on
|
||||||
|
return
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- 146 new tests (135 `feature_orders`, 6 `kell_mobile`, 5 `kell_web`); 2,044
|
||||||
|
total across all packages
|
||||||
|
- `dart analyze --fatal-infos` clean across all 11 packages and apps
|
||||||
|
|
||||||
|
> **Deferred to Stage 11C:** product-base selection from the catalogue is not
|
||||||
|
> offered — intake is free-text description only, which is what market capture
|
||||||
|
> actually needs. Reference images are captured as URLs to already-uploaded
|
||||||
|
> media; the platform camera and file pickers land with the media pipeline.
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/custom-order-ui`** — Stage 11B: Custom order creation UI
|
**`feat/custom-order-tracking`** — Stage 11C: Custom order tracking and fulfillment
|
||||||
|
|
||||||
- `CustomOrderFormPage` — customer capture, spec fields, reference image
|
- `CustomOrderTrackingPage` — custom orders filtered by fulfillment stage
|
||||||
capture, pricing and deposit entry, estimated completion picker
|
- Status progression UI driving `advanceFulfillmentStatus()`, with illegal
|
||||||
- `CustomOrderConfirmationPage` — summary with a deposit receipt
|
transitions surfaced as the domain's own rejection reasons
|
||||||
- Wire into mobile shell navigation and web routing
|
- Order history / timeline view of status changes with timestamps
|
||||||
- Tests for form validation and the creation flow
|
- Link custom order completion to inventory deduction for materials used
|
||||||
|
- Tests for the tracking UI and the status progression paths
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -414,7 +470,7 @@ These items complete planned stages and wire existing contracts into production
|
||||||
| ID | Item | Target Stage |
|
| ID | Item | Target Stage |
|
||||||
| ----- | ---------------------------------------------------------------------------------- | --------------------------- |
|
| ----- | ---------------------------------------------------------------------------------- | --------------------------- |
|
||||||
| P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C |
|
| P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C |
|
||||||
| P2.2 | Custom Orders Workflow — domain + data DONE (Stage 11A); UI open | Stage 11B/11C |
|
| P2.2 | Custom Orders Workflow — domain + data (11A) and intake UI (11B) DONE; tracking open | Stage 11C |
|
||||||
| P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B |
|
| P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B |
|
||||||
| P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C |
|
| P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C |
|
||||||
| P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
|
| P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
|
||||||
|
|
@ -1071,7 +1127,10 @@ Extend the order domain model and repository to support custom order creation wi
|
||||||
> `updateCustomOrderSpec()`. `internalNotes` was not added; `Order.notes`
|
> `updateCustomOrderSpec()`. `internalNotes` was not added; `Order.notes`
|
||||||
> already serves that purpose.
|
> already serves that purpose.
|
||||||
|
|
||||||
#### Stage 11B — Custom order creation UI
|
#### ~~Stage 11B — Custom order creation UI~~ ✅ COMPLETE
|
||||||
|
|
||||||
|
> Delivered on `feat/custom-order-ui` (2026-09-25).
|
||||||
|
> See "Stage 11B — complete (2026-09-25)" above for the full manifest.
|
||||||
|
|
||||||
##### Goal
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -1079,16 +1138,21 @@ Provide mobile and web interfaces for creating custom orders, especially at mark
|
||||||
|
|
||||||
##### Requirements
|
##### Requirements
|
||||||
|
|
||||||
- Create `CustomOrderFormPage` (shared or platform-specific presentation)
|
- ✅ Create `CustomOrderFormPage` (shared presentation, hosted by both targets)
|
||||||
- Customer information capture (name, email, phone)
|
- ✅ Customer information capture (name, email, phone)
|
||||||
- Product base selection (from catalog) or free-text description
|
- ⚠️ Free-text description delivered; product base selection from the
|
||||||
- Customization fields: material, color, size/dimensions, special instructions
|
catalogue deferred to Stage 11C
|
||||||
- Reference image capture (camera on mobile, file picker on web)
|
- ✅ Customization fields: material, color, size/dimensions, special instructions
|
||||||
- Pricing: estimated price, deposit amount, deposit payment method
|
- ⚠️ Reference images captured as URLs to already-uploaded media; the
|
||||||
- Estimated completion date picker
|
platform camera / file picker lands with the media pipeline
|
||||||
- Create `CustomOrderConfirmationPage` — summary with digital receipt for deposit
|
- ✅ Pricing: estimated price, deposit amount, deposit payment method, live
|
||||||
- Wire into mobile shell navigation and web routing
|
balance due — all money as integer cents
|
||||||
- Tests for form validation, order creation flow
|
- ✅ Estimated completion date picker, with past dates rejected
|
||||||
|
- ✅ Create `CustomOrderConfirmationPage` — summary with a deposit receipt that
|
||||||
|
carries **no** payment identifier
|
||||||
|
- ✅ Wire into mobile shell navigation (`MobileCustomOrderPage` + orders FAB)
|
||||||
|
and web routing (`/orders/custom` + `OrdersPage` action)
|
||||||
|
- ✅ Tests for form validation and the creation flow (146 new)
|
||||||
|
|
||||||
#### Stage 11C — Custom order tracking and fulfillment
|
#### Stage 11C — Custom order tracking and fulfillment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Full-screen mobile host for the shared [CustomOrderFormPage].
|
||||||
|
///
|
||||||
|
/// Market intake is the primary use case for custom orders, so the mobile
|
||||||
|
/// surface gets a dedicated route rather than an inline panel. The form,
|
||||||
|
/// validation, and confirmation receipt all come from `feature_orders` — no
|
||||||
|
/// business logic is forked for mobile.
|
||||||
|
///
|
||||||
|
/// The body is wrapped in a [SafeArea] so the submit button is never clipped
|
||||||
|
/// by the system gesture bar.
|
||||||
|
class MobileCustomOrderPage extends StatelessWidget {
|
||||||
|
final OrdersRepository repository;
|
||||||
|
|
||||||
|
/// Called with the created commission when the operator taps "Done".
|
||||||
|
///
|
||||||
|
/// Defaults to popping the route.
|
||||||
|
final void Function(Order order)? onComplete;
|
||||||
|
|
||||||
|
const MobileCustomOrderPage({super.key, required this.repository, this.onComplete});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('New Custom Order')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: CustomOrderFormPage(
|
||||||
|
repository: repository,
|
||||||
|
onComplete: (order) {
|
||||||
|
final handler = onComplete;
|
||||||
|
if (handler != null) {
|
||||||
|
handler(order);
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pop(order);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'mobile_custom_order_page.dart';
|
||||||
import 'mobile_order_detail_page.dart';
|
import 'mobile_order_detail_page.dart';
|
||||||
|
|
||||||
/// Mobile-optimized orders workspace page.
|
/// Mobile-optimized orders workspace page.
|
||||||
|
|
@ -95,18 +96,46 @@ class _MobileOrdersPageState extends State<MobileOrdersPage> {
|
||||||
return KcErrorState(message: 'Failed to load orders.', onRetry: _controller.load);
|
return KcErrorState(message: 'Failed to load orders.', onRetry: _controller.load);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildSearchBar(),
|
Column(
|
||||||
_buildFilterChips(),
|
children: [
|
||||||
_buildOrderCount(),
|
_buildSearchBar(),
|
||||||
Expanded(child: _buildOrderList()),
|
_buildFilterChips(),
|
||||||
|
_buildOrderCount(),
|
||||||
|
Expanded(child: _buildOrderList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: KcSpacing.md,
|
||||||
|
bottom: KcSpacing.md,
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
heroTag: 'new-custom-order',
|
||||||
|
onPressed: _navigateToCustomOrderForm,
|
||||||
|
icon: const Icon(Icons.design_services_outlined),
|
||||||
|
label: const Text('Custom Order'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pushes the custom order intake form and refreshes the list when a
|
||||||
|
/// commission was created.
|
||||||
|
Future<void> _navigateToCustomOrderForm() async {
|
||||||
|
setState(() => _detailPageActive = true);
|
||||||
|
await Navigator.of(context).push<Order>(
|
||||||
|
MaterialPageRoute<Order>(
|
||||||
|
builder: (_) => MobileCustomOrderPage(repository: widget.repository),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _detailPageActive = false);
|
||||||
|
await _controller.load();
|
||||||
|
}
|
||||||
|
|
||||||
/// Search bar with real-time filtering.
|
/// Search bar with real-time filtering.
|
||||||
Widget _buildSearchBar() {
|
Widget _buildSearchBar() {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kell_mobile/pages/mobile_custom_order_page.dart';
|
||||||
|
|
||||||
|
Widget _buildPage({void Function(Order order)? onComplete}) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: MobileCustomOrderPage(repository: FakeOrdersRepository(), onComplete: onComplete),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('MobileCustomOrderPage', () {
|
||||||
|
testWidgets('shows the New Custom Order app bar title', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage());
|
||||||
|
|
||||||
|
expect(find.byType(AppBar), findsOneWidget);
|
||||||
|
expect(find.text('New Custom Order'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hosts the shared CustomOrderFormPage', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage());
|
||||||
|
|
||||||
|
expect(find.byType(CustomOrderFormPage), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('wraps the form in a SafeArea', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage());
|
||||||
|
|
||||||
|
expect(find.byType(SafeArea), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the customer and pricing sections', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage());
|
||||||
|
|
||||||
|
expect(find.text('Customer'), findsOneWidget);
|
||||||
|
expect(find.text('Pricing'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the submit action', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage());
|
||||||
|
|
||||||
|
expect(find.text('Create Custom Order'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('submitting an empty form shows validation errors', (tester) async {
|
||||||
|
// The intake form is long; the default 800×600 test view puts the submit
|
||||||
|
// button outside the hit-test area. On a device the page scrolls.
|
||||||
|
tester.view.physicalSize = const Size(1200, 4000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildPage());
|
||||||
|
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Customer name is required.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,21 @@ abstract final class AppNavigation {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Orders → Custom order intake ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Orders page "New Custom Order" action → the custom order intake form.
|
||||||
|
static void ordersToCustomOrder(BuildContext context) {
|
||||||
|
navigateTo(context, NavigationTarget(route: AppRoutes.customOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom order confirmation → Orders page, pre-selecting the new order.
|
||||||
|
static void customOrderToOrders(BuildContext context, {required String orderId}) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.orders, arguments: {'selectedId': orderId}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Products → Policy ─────────────────────────────────────────────────
|
// ── Products → Policy ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Product draft → Policy page filtered to "Product Compliance".
|
/// Product draft → Policy page filtered to "Product Compliance".
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ abstract final class AppRoutes {
|
||||||
static const String inventory = '/inventory';
|
static const String inventory = '/inventory';
|
||||||
static const String products = '/products';
|
static const String products = '/products';
|
||||||
static const String orders = '/orders';
|
static const String orders = '/orders';
|
||||||
|
static const String customOrder = '/orders/custom';
|
||||||
static const String finance = '/finance';
|
static const String finance = '/finance';
|
||||||
static const String policy = '/policy';
|
static const String policy = '/policy';
|
||||||
static const String integrations = '/integrations';
|
static const String integrations = '/integrations';
|
||||||
|
|
@ -83,12 +84,25 @@ abstract final class AppRoutes {
|
||||||
repository: AppScope.of(context).ordersRepository,
|
repository: AppScope.of(context).ordersRepository,
|
||||||
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
|
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
|
||||||
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
|
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
|
||||||
|
onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context),
|
||||||
initialFilter: args['filter'],
|
initialFilter: args['filter'],
|
||||||
initialQuery: args['query'],
|
initialQuery: args['query'],
|
||||||
initialSelectedId: args['selectedId'],
|
initialSelectedId: args['selectedId'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
case customOrder:
|
||||||
|
return _buildRoute(
|
||||||
|
settings,
|
||||||
|
(context) => AppShell(
|
||||||
|
selectedRoute: orders,
|
||||||
|
title: 'New Custom Order',
|
||||||
|
child: CustomOrderFormPage(
|
||||||
|
repository: AppScope.of(context).ordersRepository,
|
||||||
|
onComplete: (order) => AppNavigation.customOrderToOrders(context, orderId: order.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
case finance:
|
case finance:
|
||||||
return _buildRoute(
|
return _buildRoute(
|
||||||
settings,
|
settings,
|
||||||
|
|
|
||||||
|
|
@ -176,10 +176,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -192,10 +192,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -253,10 +253,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -269,10 +269,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.3"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kell_web/app.dart';
|
||||||
|
import 'package:kell_web/composition/app_config.dart';
|
||||||
|
import 'package:kell_web/composition/app_scope.dart';
|
||||||
|
import 'package:kell_web/composition/app_services.dart';
|
||||||
|
import 'package:kell_web/routing/app_routes.dart';
|
||||||
|
|
||||||
|
Widget _buildTestApp() {
|
||||||
|
const config = AppConfig(
|
||||||
|
environment: AppEnvironment.fake,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates the running app to [route].
|
||||||
|
Future<void> _go(WidgetTester tester, String route) async {
|
||||||
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
unawaited(navigator.pushReplacementNamed(route));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AppRoutes — custom order route', () {
|
||||||
|
test('exposes the custom order path', () {
|
||||||
|
expect(AppRoutes.customOrder, '/orders/custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the route renders the custom order form inside the shell', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _go(tester, AppRoutes.customOrder);
|
||||||
|
|
||||||
|
expect(find.byType(CustomOrderFormPage), findsOneWidget);
|
||||||
|
expect(find.text('New Custom Order'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the route renders the intake sections', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _go(tester, AppRoutes.customOrder);
|
||||||
|
|
||||||
|
expect(find.text('Customer'), findsOneWidget);
|
||||||
|
expect(find.text('Commission'), findsOneWidget);
|
||||||
|
expect(find.text('Pricing'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the orders route offers the custom order action', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _go(tester, AppRoutes.orders);
|
||||||
|
|
||||||
|
expect(find.text('New Custom Order'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the orders action opens the intake form', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildTestApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _go(tester, AppRoutes.orders);
|
||||||
|
await tester.tap(find.text('New Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(CustomOrderFormPage), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -34,10 +34,13 @@ standard catalogue orders and made-to-order custom commissions behind a single
|
||||||
orders across every pipeline stage) and `WooCommerceOrdersRepository`
|
orders across every pipeline stage) and `WooCommerceOrdersRepository`
|
||||||
(REST API v3, custom order state round-tripped through `_kc_`-prefixed order
|
(REST API v3, custom order state round-tripped through `_kc_`-prefixed order
|
||||||
meta-data).
|
meta-data).
|
||||||
- **Application layer** — `OrdersController`, `CheckoutController`, and the
|
- **Application layer** — `OrdersController`, `CheckoutController`,
|
||||||
`GetOrders` / `UpdateOrderStatus` / `AddOrderNote` / `CreateOrder` use cases.
|
`CustomOrderFormController`, and the `GetOrders` / `UpdateOrderStatus` /
|
||||||
|
`AddOrderNote` / `CreateOrder` / `CreateCustomOrder` / `RecordDeposit` use
|
||||||
|
cases.
|
||||||
- **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`,
|
- **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`,
|
||||||
`OrderStatusChip`.
|
`OrderStatusChip`, `CustomOrderFormPage`, `CustomOrderConfirmationPage`,
|
||||||
|
`FulfillmentStatusChip`, `showReferenceImageDialog`.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
|
@ -80,6 +83,25 @@ await repository.recordDeposit(
|
||||||
await repository.advanceFulfillmentStatus(order.id, FulfillmentStatus.inProduction);
|
await repository.advanceFulfillmentStatus(order.id, FulfillmentStatus.inProduction);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom order intake UI
|
||||||
|
|
||||||
|
`CustomOrderFormPage` captures an entire commission in one screen — customer
|
||||||
|
contact, description, specification, reference images, pricing and deposit, and
|
||||||
|
the estimated completion date. It owns a `CustomOrderFormController` unless one
|
||||||
|
is injected, so a route or a shell can mount it with only a repository:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
CustomOrderFormPage(
|
||||||
|
repository: services.ordersRepository,
|
||||||
|
onComplete: (order) => Navigator.of(context).pop(order),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Money is typed as free text and converted to integer cents by
|
||||||
|
`CustomOrderFormController.parseMoneyToCents` before it reaches the domain, so
|
||||||
|
no `double` arithmetic ever decides a price. On success the page swaps to
|
||||||
|
`CustomOrderConfirmationPage`, which renders the deposit receipt.
|
||||||
|
|
||||||
## Observability and privacy
|
## Observability and privacy
|
||||||
|
|
||||||
Repository implementations accept an optional `KcLogger` from `core`. Failed
|
Repository implementations accept an optional `KcLogger` from `core`. Failed
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ library;
|
||||||
|
|
||||||
export 'src/application/add_order_note.dart';
|
export 'src/application/add_order_note.dart';
|
||||||
export 'src/application/checkout_controller.dart';
|
export 'src/application/checkout_controller.dart';
|
||||||
|
export 'src/application/create_custom_order.dart';
|
||||||
export 'src/application/create_order.dart';
|
export 'src/application/create_order.dart';
|
||||||
|
export 'src/application/custom_order_form_controller.dart';
|
||||||
export 'src/application/get_orders.dart';
|
export 'src/application/get_orders.dart';
|
||||||
export 'src/application/orders_controller.dart';
|
export 'src/application/orders_controller.dart';
|
||||||
export 'src/application/payment_service.dart';
|
export 'src/application/payment_service.dart';
|
||||||
|
export 'src/application/record_deposit.dart';
|
||||||
export 'src/application/update_order_status.dart';
|
export 'src/application/update_order_status.dart';
|
||||||
export 'src/data/fake_orders_repository.dart';
|
export 'src/data/fake_orders_repository.dart';
|
||||||
export 'src/data/fake_payment_service.dart';
|
export 'src/data/fake_payment_service.dart';
|
||||||
|
|
@ -23,9 +26,13 @@ export 'src/domain/order_type.dart';
|
||||||
export 'src/domain/orders_repository.dart';
|
export 'src/domain/orders_repository.dart';
|
||||||
export 'src/domain/payment_method.dart';
|
export 'src/domain/payment_method.dart';
|
||||||
export 'src/domain/payment_result.dart';
|
export 'src/domain/payment_result.dart';
|
||||||
|
export 'src/presentation/custom_order_confirmation_page.dart';
|
||||||
|
export 'src/presentation/custom_order_form_page.dart';
|
||||||
export 'src/presentation/orders_action_snack_bar.dart';
|
export 'src/presentation/orders_action_snack_bar.dart';
|
||||||
export 'src/presentation/orders_page.dart';
|
export 'src/presentation/orders_page.dart';
|
||||||
export 'src/presentation/widgets/add_note_dialog.dart';
|
export 'src/presentation/widgets/add_note_dialog.dart';
|
||||||
|
export 'src/presentation/widgets/fulfillment_status_chip.dart';
|
||||||
export 'src/presentation/widgets/order_card.dart';
|
export 'src/presentation/widgets/order_card.dart';
|
||||||
export 'src/presentation/widgets/order_detail_panel.dart';
|
export 'src/presentation/widgets/order_detail_panel.dart';
|
||||||
export 'src/presentation/widgets/order_status_chip.dart';
|
export 'src/presentation/widgets/order_status_chip.dart';
|
||||||
|
export 'src/presentation/widgets/reference_image_dialog.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import '../domain/custom_order_spec.dart';
|
||||||
|
import '../domain/deposit_info.dart';
|
||||||
|
import '../domain/order.dart';
|
||||||
|
import '../domain/order_action_result.dart';
|
||||||
|
import '../domain/orders_repository.dart';
|
||||||
|
|
||||||
|
/// Use case: create a made-to-order commission and persist it.
|
||||||
|
///
|
||||||
|
/// The repository stamps [Order.orderType], the supplied [CustomOrderSpec],
|
||||||
|
/// the optional [DepositInfo], and the initial fulfillment stage, so callers
|
||||||
|
/// never have to assemble a consistent custom-order aggregate by hand.
|
||||||
|
class CreateCustomOrder {
|
||||||
|
final OrdersRepository repository;
|
||||||
|
|
||||||
|
CreateCustomOrder(this.repository);
|
||||||
|
|
||||||
|
Future<OrderActionResult> call(
|
||||||
|
Order order, {
|
||||||
|
required CustomOrderSpec spec,
|
||||||
|
DepositInfo? deposit,
|
||||||
|
}) => repository.createCustomOrder(order, spec: spec, deposit: deposit);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../domain/custom_order_spec.dart';
|
||||||
|
import '../domain/deposit_info.dart';
|
||||||
|
import '../domain/order.dart';
|
||||||
|
import '../domain/order_action_result.dart';
|
||||||
|
import '../domain/order_item.dart';
|
||||||
|
import '../domain/order_source.dart';
|
||||||
|
import '../domain/order_status.dart';
|
||||||
|
import '../domain/payment_method.dart';
|
||||||
|
import 'create_custom_order.dart';
|
||||||
|
import 'record_deposit.dart';
|
||||||
|
|
||||||
|
/// The fields captured by the custom order intake form.
|
||||||
|
///
|
||||||
|
/// Used as the key space for [CustomOrderFormController.errors] so the
|
||||||
|
/// presentation layer can attach a message to the right input without
|
||||||
|
/// matching on free-text strings.
|
||||||
|
enum CustomOrderField {
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone,
|
||||||
|
description,
|
||||||
|
material,
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
dimensions,
|
||||||
|
estimatedPrice,
|
||||||
|
depositAmount,
|
||||||
|
estimatedCompletionDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller for the custom order intake form.
|
||||||
|
///
|
||||||
|
/// Holds the operator's in-progress input, validates it, and submits it as a
|
||||||
|
/// made-to-order commission through [CreateCustomOrder]. When the operator
|
||||||
|
/// collected a deposit at the stall, the deposit is recorded immediately
|
||||||
|
/// afterwards through [RecordDeposit].
|
||||||
|
///
|
||||||
|
/// Money is captured as free text (e.g. `'72.00'`) and converted to integer
|
||||||
|
/// cents by [parseMoneyToCents] before it reaches the domain, so no `double`
|
||||||
|
/// arithmetic ever decides a price.
|
||||||
|
///
|
||||||
|
/// ## Privacy
|
||||||
|
///
|
||||||
|
/// The controller never stores or forwards a payment identifier. Deposit
|
||||||
|
/// transaction IDs belong to `PaymentResult`; only the amount, the timestamp,
|
||||||
|
/// and the method label reach the order aggregate.
|
||||||
|
class CustomOrderFormController extends ChangeNotifier {
|
||||||
|
final CreateCustomOrder _createCustomOrder;
|
||||||
|
final RecordDeposit? _recordDeposit;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
final String Function() _generateId;
|
||||||
|
|
||||||
|
CustomOrderFormController({
|
||||||
|
required CreateCustomOrder createCustomOrder,
|
||||||
|
RecordDeposit? recordDeposit,
|
||||||
|
DateTime Function()? now,
|
||||||
|
String Function()? generateId,
|
||||||
|
}) : _createCustomOrder = createCustomOrder,
|
||||||
|
_recordDeposit = recordDeposit,
|
||||||
|
_now = now ?? DateTime.now,
|
||||||
|
_generateId = generateId ?? _defaultIdGenerator;
|
||||||
|
|
||||||
|
// ── Customer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
String customerName = '';
|
||||||
|
String customerEmail = '';
|
||||||
|
String customerPhone = '';
|
||||||
|
|
||||||
|
// ── Specification ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A free-text description of the piece, or the selected catalogue base.
|
||||||
|
String description = '';
|
||||||
|
|
||||||
|
String material = '';
|
||||||
|
String color = '';
|
||||||
|
String size = '';
|
||||||
|
String dimensions = '';
|
||||||
|
String specialInstructions = '';
|
||||||
|
|
||||||
|
/// Reference image URLs supplied by the customer.
|
||||||
|
///
|
||||||
|
/// The form captures references to already-uploaded media (camera roll on
|
||||||
|
/// mobile, file picker on web); raw image bytes never enter the domain.
|
||||||
|
final List<String> referenceImageUrls = [];
|
||||||
|
|
||||||
|
DateTime? estimatedCompletionDate;
|
||||||
|
|
||||||
|
// ── Pricing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The agreed total price, as typed by the operator (e.g. `'72.00'`).
|
||||||
|
String estimatedPrice = '';
|
||||||
|
|
||||||
|
/// The deposit amount, as typed by the operator. Empty means no deposit.
|
||||||
|
String depositAmount = '';
|
||||||
|
|
||||||
|
/// Whether the deposit was collected during intake.
|
||||||
|
///
|
||||||
|
/// When `true` and a [RecordDeposit] use case was injected, the deposit is
|
||||||
|
/// recorded against the order immediately after creation, turning a quote
|
||||||
|
/// into a committed commission.
|
||||||
|
bool depositCollected = false;
|
||||||
|
|
||||||
|
/// The method used to collect the deposit.
|
||||||
|
PaymentMethod depositMethod = PaymentMethod.card;
|
||||||
|
|
||||||
|
/// Where the finished piece is handed over. Defaults to customer pickup.
|
||||||
|
String shippingAddress = '';
|
||||||
|
|
||||||
|
/// The channel this intake happened through.
|
||||||
|
OrderSource source = OrderSource.market;
|
||||||
|
|
||||||
|
// ── Submission state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Whether a submission is currently in flight.
|
||||||
|
bool isSubmitting = false;
|
||||||
|
|
||||||
|
/// Field-level validation messages from the last [validate] or [submit].
|
||||||
|
Map<CustomOrderField, String> errors = const {};
|
||||||
|
|
||||||
|
/// The result of the last submission attempt, or `null` if none.
|
||||||
|
OrderActionResult? lastResult;
|
||||||
|
|
||||||
|
/// The created order once submission succeeded.
|
||||||
|
Order? createdOrder;
|
||||||
|
|
||||||
|
/// Whether the form has been submitted successfully.
|
||||||
|
bool get isComplete => createdOrder != null;
|
||||||
|
|
||||||
|
/// The agreed total in integer cents, or `null` when not yet valid.
|
||||||
|
int? get estimatedPriceCents => parseMoneyToCents(estimatedPrice);
|
||||||
|
|
||||||
|
/// The deposit in integer cents, or `null` when empty or not yet valid.
|
||||||
|
int? get depositAmountCents => parseMoneyToCents(depositAmount);
|
||||||
|
|
||||||
|
/// The balance that would remain after the deposit, in cents.
|
||||||
|
///
|
||||||
|
/// `null` until a valid total has been entered. Never negative.
|
||||||
|
int? get balanceDueCents {
|
||||||
|
final total = estimatedPriceCents;
|
||||||
|
if (total == null) return null;
|
||||||
|
final deposit = depositAmountCents ?? 0;
|
||||||
|
final remaining = total - deposit;
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field setters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void setCustomerName(String value) => _update(() => customerName = value);
|
||||||
|
|
||||||
|
void setCustomerEmail(String value) => _update(() => customerEmail = value);
|
||||||
|
|
||||||
|
void setCustomerPhone(String value) => _update(() => customerPhone = value);
|
||||||
|
|
||||||
|
void setDescription(String value) => _update(() => description = value);
|
||||||
|
|
||||||
|
void setMaterial(String value) => _update(() => material = value);
|
||||||
|
|
||||||
|
void setColor(String value) => _update(() => color = value);
|
||||||
|
|
||||||
|
void setSize(String value) => _update(() => size = value);
|
||||||
|
|
||||||
|
void setDimensions(String value) => _update(() => dimensions = value);
|
||||||
|
|
||||||
|
void setSpecialInstructions(String value) => _update(() => specialInstructions = value);
|
||||||
|
|
||||||
|
void setEstimatedPrice(String value) => _update(() => estimatedPrice = value);
|
||||||
|
|
||||||
|
void setDepositAmount(String value) => _update(() => depositAmount = value);
|
||||||
|
|
||||||
|
void setDepositCollected(bool value) => _update(() => depositCollected = value);
|
||||||
|
|
||||||
|
void setDepositMethod(PaymentMethod value) => _update(() => depositMethod = value);
|
||||||
|
|
||||||
|
void setShippingAddress(String value) => _update(() => shippingAddress = value);
|
||||||
|
|
||||||
|
void setSource(OrderSource value) => _update(() => source = value);
|
||||||
|
|
||||||
|
void setEstimatedCompletionDate(DateTime? value) =>
|
||||||
|
_update(() => estimatedCompletionDate = value);
|
||||||
|
|
||||||
|
/// Adds a reference image URL, ignoring blanks and duplicates.
|
||||||
|
void addReferenceImageUrl(String url) {
|
||||||
|
final trimmed = url.trim();
|
||||||
|
if (trimmed.isEmpty || referenceImageUrls.contains(trimmed)) return;
|
||||||
|
referenceImageUrls.add(trimmed);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a previously added reference image URL.
|
||||||
|
void removeReferenceImageUrl(String url) {
|
||||||
|
if (referenceImageUrls.remove(url)) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Validates the current input and publishes [errors].
|
||||||
|
///
|
||||||
|
/// Returns `true` when the form is ready to submit.
|
||||||
|
bool validate() {
|
||||||
|
final found = <CustomOrderField, String>{};
|
||||||
|
|
||||||
|
if (customerName.trim().isEmpty) {
|
||||||
|
found[CustomOrderField.customerName] = 'Customer name is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final email = customerEmail.trim();
|
||||||
|
if (email.isEmpty) {
|
||||||
|
found[CustomOrderField.customerEmail] = 'Customer email is required.';
|
||||||
|
} else if (!_looksLikeEmail(email)) {
|
||||||
|
found[CustomOrderField.customerEmail] = 'Enter a valid email address.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.trim().isEmpty) {
|
||||||
|
found[CustomOrderField.description] = 'Describe the piece being commissioned.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (material.trim().isEmpty) {
|
||||||
|
found[CustomOrderField.material] = 'Material is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.trim().isEmpty) {
|
||||||
|
found[CustomOrderField.color] = 'Colour is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size.trim().isEmpty && dimensions.trim().isEmpty) {
|
||||||
|
const message = 'Enter a size or dimensions.';
|
||||||
|
found[CustomOrderField.size] = message;
|
||||||
|
found[CustomOrderField.dimensions] = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalCents = parseMoneyToCents(estimatedPrice);
|
||||||
|
if (estimatedPrice.trim().isEmpty) {
|
||||||
|
found[CustomOrderField.estimatedPrice] = 'Estimated price is required.';
|
||||||
|
} else if (totalCents == null) {
|
||||||
|
found[CustomOrderField.estimatedPrice] = 'Enter a valid amount (e.g. 72.00).';
|
||||||
|
} else if (totalCents <= 0) {
|
||||||
|
found[CustomOrderField.estimatedPrice] = 'Estimated price must be greater than zero.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depositAmount.trim().isNotEmpty) {
|
||||||
|
final depositCents = parseMoneyToCents(depositAmount);
|
||||||
|
if (depositCents == null) {
|
||||||
|
found[CustomOrderField.depositAmount] = 'Enter a valid amount (e.g. 24.00).';
|
||||||
|
} else if (depositCents <= 0) {
|
||||||
|
found[CustomOrderField.depositAmount] = 'Deposit must be greater than zero.';
|
||||||
|
} else if (totalCents != null && depositCents > totalCents) {
|
||||||
|
found[CustomOrderField.depositAmount] = 'Deposit cannot exceed the estimated price.';
|
||||||
|
}
|
||||||
|
} else if (depositCollected) {
|
||||||
|
found[CustomOrderField.depositAmount] = 'Enter the deposit amount that was collected.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final due = estimatedCompletionDate;
|
||||||
|
if (due != null && due.isBefore(_startOfToday())) {
|
||||||
|
found[CustomOrderField.estimatedCompletionDate] =
|
||||||
|
'Estimated completion cannot be in the past.';
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = Map.unmodifiable(found);
|
||||||
|
notifyListeners();
|
||||||
|
return found.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validation message for [field], or `null` when it is valid.
|
||||||
|
String? errorFor(CustomOrderField field) => errors[field];
|
||||||
|
|
||||||
|
// ── Submission ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Validates and submits the form.
|
||||||
|
///
|
||||||
|
/// On success [createdOrder] holds the persisted commission and [lastResult]
|
||||||
|
/// reports the outcome. When [depositCollected] is set and a [RecordDeposit]
|
||||||
|
/// use case was injected, the deposit is recorded straight afterwards; a
|
||||||
|
/// failure to record it does **not** discard the created order — the failure
|
||||||
|
/// surfaces through [lastResult] so the operator can retry from the order
|
||||||
|
/// detail view.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the commission was created.
|
||||||
|
Future<bool> submit() async {
|
||||||
|
if (isSubmitting) return false;
|
||||||
|
if (!validate()) return false;
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
lastResult = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final totalCents = estimatedPriceCents!;
|
||||||
|
final depositCents = depositAmountCents;
|
||||||
|
final spec = buildSpec();
|
||||||
|
final order = _buildOrder(totalCents: totalCents);
|
||||||
|
|
||||||
|
final result = await _createCustomOrder(
|
||||||
|
order,
|
||||||
|
spec: spec,
|
||||||
|
deposit: depositCents == null
|
||||||
|
? null
|
||||||
|
: DepositInfo.quoted(totalAmountCents: totalCents, depositAmountCents: depositCents),
|
||||||
|
);
|
||||||
|
|
||||||
|
lastResult = result;
|
||||||
|
if (!result.success) return false;
|
||||||
|
|
||||||
|
createdOrder = result.updatedOrder;
|
||||||
|
|
||||||
|
final recordDeposit = _recordDeposit;
|
||||||
|
if (depositCollected && depositCents != null && recordDeposit != null) {
|
||||||
|
final depositResult = await recordDeposit(
|
||||||
|
order.id,
|
||||||
|
amountCents: depositCents,
|
||||||
|
paidAt: _now(),
|
||||||
|
totalAmountCents: totalCents,
|
||||||
|
);
|
||||||
|
lastResult = depositResult;
|
||||||
|
if (depositResult.success) {
|
||||||
|
createdOrder = depositResult.updatedOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the [CustomOrderSpec] described by the current input.
|
||||||
|
///
|
||||||
|
/// Exposed so the confirmation summary never has to reassemble the
|
||||||
|
/// specification by hand.
|
||||||
|
CustomOrderSpec buildSpec() {
|
||||||
|
return CustomOrderSpec(
|
||||||
|
material: material.trim(),
|
||||||
|
color: color.trim(),
|
||||||
|
size: size.trim().isEmpty ? null : size.trim(),
|
||||||
|
dimensions: dimensions.trim().isEmpty ? null : dimensions.trim(),
|
||||||
|
referenceImageUrls: List<String>.from(referenceImageUrls),
|
||||||
|
notes: specialInstructions.trim().isEmpty ? null : specialInstructions.trim(),
|
||||||
|
estimatedCompletionDate: estimatedCompletionDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears every field and submission result so a new intake can begin.
|
||||||
|
void reset() {
|
||||||
|
customerName = '';
|
||||||
|
customerEmail = '';
|
||||||
|
customerPhone = '';
|
||||||
|
description = '';
|
||||||
|
material = '';
|
||||||
|
color = '';
|
||||||
|
size = '';
|
||||||
|
dimensions = '';
|
||||||
|
specialInstructions = '';
|
||||||
|
referenceImageUrls.clear();
|
||||||
|
estimatedCompletionDate = null;
|
||||||
|
estimatedPrice = '';
|
||||||
|
depositAmount = '';
|
||||||
|
depositCollected = false;
|
||||||
|
depositMethod = PaymentMethod.card;
|
||||||
|
shippingAddress = '';
|
||||||
|
source = OrderSource.market;
|
||||||
|
errors = const {};
|
||||||
|
lastResult = null;
|
||||||
|
createdOrder = null;
|
||||||
|
isSubmitting = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Money parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parses a typed money string into integer cents.
|
||||||
|
///
|
||||||
|
/// Accepts an optional currency symbol, thousands separators, and at most
|
||||||
|
/// two decimal places (`'$1,299.5'` → `129950`). Returns `null` when the
|
||||||
|
/// input is empty or is not a money value — the caller decides whether that
|
||||||
|
/// is a validation error.
|
||||||
|
static int? parseMoneyToCents(String raw) {
|
||||||
|
final cleaned = raw.trim().replaceAll(RegExp(r'[$,\s]'), '');
|
||||||
|
if (cleaned.isEmpty) return null;
|
||||||
|
if (!RegExp(r'^\d+(\.\d{1,2})?$').hasMatch(cleaned)) return null;
|
||||||
|
|
||||||
|
final parts = cleaned.split('.');
|
||||||
|
final dollars = int.parse(parts[0]);
|
||||||
|
final centsPart = parts.length > 1 ? parts[1].padRight(2, '0') : '00';
|
||||||
|
return dollars * 100 + int.parse(centsPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Order _buildOrder({required int totalCents}) {
|
||||||
|
final spec = buildSpec();
|
||||||
|
final customizations = <String, String>{
|
||||||
|
'material': spec.material,
|
||||||
|
'color': spec.color,
|
||||||
|
if (spec.size != null) 'size': spec.size!,
|
||||||
|
if (spec.dimensions != null) 'dimensions': spec.dimensions!,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Order(
|
||||||
|
id: _generateId(),
|
||||||
|
customerName: customerName.trim(),
|
||||||
|
customerEmail: customerEmail.trim(),
|
||||||
|
customerPhone: customerPhone.trim().isEmpty ? null : customerPhone.trim(),
|
||||||
|
orderDate: _now(),
|
||||||
|
status: OrderStatus.pending,
|
||||||
|
shippingAddress: shippingAddress.trim().isEmpty ? 'Customer pickup' : shippingAddress.trim(),
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
productName: description.trim(),
|
||||||
|
sku: customSku,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: totalCents / 100,
|
||||||
|
customizations: customizations,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
notes: specialInstructions.trim().isEmpty ? null : specialInstructions.trim(),
|
||||||
|
paymentMethod: depositCollected ? depositMethod.label : null,
|
||||||
|
source: source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _update(void Function() mutate) {
|
||||||
|
mutate();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _startOfToday() {
|
||||||
|
final now = _now();
|
||||||
|
return DateTime(now.year, now.month, now.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _looksLikeEmail(String value) =>
|
||||||
|
RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value);
|
||||||
|
|
||||||
|
/// The SKU used for the single synthetic line item on a commission.
|
||||||
|
static const String customSku = 'CUSTOM';
|
||||||
|
|
||||||
|
static String _defaultIdGenerator() =>
|
||||||
|
'KC-C${DateTime.now().toUtc().millisecondsSinceEpoch.toRadixString(36).toUpperCase()}';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import '../domain/order_action_result.dart';
|
||||||
|
import '../domain/orders_repository.dart';
|
||||||
|
|
||||||
|
/// Use case: record a deposit payment against a custom order.
|
||||||
|
///
|
||||||
|
/// [amountCents] is integer cents, consistent with `DepositInfo` and
|
||||||
|
/// `PaymentResult`. No payment identifier is accepted here — transaction IDs
|
||||||
|
/// belong to `PaymentResult` and must never reach order meta-data.
|
||||||
|
class RecordDeposit {
|
||||||
|
final OrdersRepository repository;
|
||||||
|
|
||||||
|
RecordDeposit(this.repository);
|
||||||
|
|
||||||
|
Future<OrderActionResult> call(
|
||||||
|
String id, {
|
||||||
|
required int amountCents,
|
||||||
|
required DateTime paidAt,
|
||||||
|
int? totalAmountCents,
|
||||||
|
}) => repository.recordDeposit(
|
||||||
|
id,
|
||||||
|
amountCents: amountCents,
|
||||||
|
paidAt: paidAt,
|
||||||
|
totalAmountCents: totalAmountCents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../domain/deposit_info.dart';
|
||||||
|
import '../domain/fulfillment_status.dart';
|
||||||
|
import '../domain/order.dart';
|
||||||
|
import 'widgets/fulfillment_status_chip.dart';
|
||||||
|
|
||||||
|
/// Confirmation screen shown after a custom order has been created.
|
||||||
|
///
|
||||||
|
/// Summarises the commission and renders a deposit receipt when a deposit was
|
||||||
|
/// collected. The receipt shows the amount, the method, the date, and the
|
||||||
|
/// balance due — never a payment identifier, which belongs to `PaymentResult`
|
||||||
|
/// alone.
|
||||||
|
class CustomOrderConfirmationPage extends StatelessWidget {
|
||||||
|
/// The persisted commission.
|
||||||
|
final Order order;
|
||||||
|
|
||||||
|
/// Called when the operator starts another intake.
|
||||||
|
final VoidCallback? onNewOrder;
|
||||||
|
|
||||||
|
/// Called when the operator finishes and leaves the flow.
|
||||||
|
final VoidCallback? onDone;
|
||||||
|
|
||||||
|
const CustomOrderConfirmationPage({super.key, required this.order, this.onNewOrder, this.onDone});
|
||||||
|
|
||||||
|
/// Formats a date as `YYYY-MM-DD`.
|
||||||
|
static String formatDate(DateTime date) =>
|
||||||
|
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final spec = order.customOrderSpec;
|
||||||
|
final deposit = order.depositInfo;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(KcSpacing.lg),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Header ───────────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle, color: KcColors.success, size: 32),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
Expanded(child: Text('Custom Order Created', style: theme.textTheme.headlineSmall)),
|
||||||
|
FulfillmentStatusChip(status: order.fulfillmentStatus ?? FulfillmentStatus.quoted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.xs),
|
||||||
|
Text(order.id, style: theme.textTheme.titleMedium?.copyWith(color: KcColors.neutral)),
|
||||||
|
const SizedBox(height: KcSpacing.lg),
|
||||||
|
|
||||||
|
// ── Customer ─────────────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Customer'),
|
||||||
|
_SummaryRow(label: 'Name', value: order.customerName),
|
||||||
|
_SummaryRow(label: 'Email', value: order.customerEmail),
|
||||||
|
if (order.customerPhone != null) _SummaryRow(label: 'Phone', value: order.customerPhone!),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
|
// ── Specification ────────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Specification'),
|
||||||
|
if (order.items.isNotEmpty)
|
||||||
|
_SummaryRow(label: 'Description', value: order.items.first.productName),
|
||||||
|
if (spec != null) ...[
|
||||||
|
_SummaryRow(label: 'Material', value: spec.material),
|
||||||
|
_SummaryRow(label: 'Colour', value: spec.color),
|
||||||
|
if (spec.size != null) _SummaryRow(label: 'Size', value: spec.size!),
|
||||||
|
if (spec.dimensions != null) _SummaryRow(label: 'Dimensions', value: spec.dimensions!),
|
||||||
|
if (spec.notes != null) _SummaryRow(label: 'Special Instructions', value: spec.notes!),
|
||||||
|
if (spec.estimatedCompletionDate != null)
|
||||||
|
_SummaryRow(
|
||||||
|
label: 'Estimated Completion',
|
||||||
|
value: formatDate(spec.estimatedCompletionDate!),
|
||||||
|
),
|
||||||
|
if (spec.hasReferenceImages)
|
||||||
|
_SummaryRow(
|
||||||
|
label: 'Reference Images',
|
||||||
|
value: '${spec.referenceImageUrls.length} attached',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
|
// ── Deposit receipt ──────────────────────────────────────────────
|
||||||
|
if (deposit != null)
|
||||||
|
_DepositReceipt(deposit: deposit, paymentMethod: order.paymentMethod),
|
||||||
|
const SizedBox(height: KcSpacing.lg),
|
||||||
|
|
||||||
|
// ── Actions ──────────────────────────────────────────────────────
|
||||||
|
Wrap(
|
||||||
|
spacing: KcSpacing.sm,
|
||||||
|
runSpacing: KcSpacing.sm,
|
||||||
|
children: [
|
||||||
|
if (onNewOrder != null)
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onNewOrder,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('New Custom Order'),
|
||||||
|
),
|
||||||
|
if (onDone != null)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onDone,
|
||||||
|
icon: const Icon(Icons.done),
|
||||||
|
label: const Text('Done'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deposit receipt block.
|
||||||
|
///
|
||||||
|
/// Deliberately carries no payment identifier — order meta-data is readable by
|
||||||
|
/// any store administrator, so transaction IDs stay on `PaymentResult`.
|
||||||
|
class _DepositReceipt extends StatelessWidget {
|
||||||
|
final DepositInfo deposit;
|
||||||
|
final String? paymentMethod;
|
||||||
|
|
||||||
|
const _DepositReceipt({required this.deposit, this.paymentMethod});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return KcCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.receipt_long, color: KcColors.denimBlue),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
Text('Deposit Receipt', style: theme.textTheme.titleMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
_SummaryRow(label: 'Agreed Total', value: deposit.totalFormatted),
|
||||||
|
_SummaryRow(
|
||||||
|
label: deposit.depositPaid ? 'Deposit Paid' : 'Deposit Due',
|
||||||
|
value: deposit.depositFormatted,
|
||||||
|
),
|
||||||
|
if (paymentMethod != null) _SummaryRow(label: 'Method', value: paymentMethod!),
|
||||||
|
if (deposit.depositDate != null)
|
||||||
|
_SummaryRow(
|
||||||
|
label: 'Paid On',
|
||||||
|
value: CustomOrderConfirmationPage.formatDate(deposit.depositDate!),
|
||||||
|
),
|
||||||
|
_SummaryRow(label: 'Balance Due', value: deposit.balanceDueFormatted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const _SummaryRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral)),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../application/create_custom_order.dart';
|
||||||
|
import '../application/custom_order_form_controller.dart';
|
||||||
|
import '../application/record_deposit.dart';
|
||||||
|
import '../domain/deposit_info.dart';
|
||||||
|
import '../domain/order.dart';
|
||||||
|
import '../domain/order_source.dart';
|
||||||
|
import '../domain/orders_repository.dart';
|
||||||
|
import '../domain/payment_method.dart';
|
||||||
|
import 'custom_order_confirmation_page.dart';
|
||||||
|
import 'widgets/reference_image_dialog.dart';
|
||||||
|
|
||||||
|
/// Intake form for a made-to-order commission.
|
||||||
|
///
|
||||||
|
/// Captures customer contact details, the piece description, the
|
||||||
|
/// specification (material, colour, size or dimensions, special
|
||||||
|
/// instructions), reference images, pricing and deposit, and the estimated
|
||||||
|
/// completion date. On successful submission the page swaps to
|
||||||
|
/// [CustomOrderConfirmationPage], which renders the deposit receipt.
|
||||||
|
///
|
||||||
|
/// The page owns a [CustomOrderFormController] built from [repository] unless
|
||||||
|
/// one is injected, so both the web route and the mobile shell can mount it
|
||||||
|
/// without duplicating wiring.
|
||||||
|
class CustomOrderFormPage extends StatefulWidget {
|
||||||
|
/// Repository used to build the default controller.
|
||||||
|
///
|
||||||
|
/// Ignored when [controller] is supplied.
|
||||||
|
final OrdersRepository? repository;
|
||||||
|
|
||||||
|
/// An externally owned controller. When supplied it is **not** disposed by
|
||||||
|
/// this page — the owner keeps that responsibility.
|
||||||
|
final CustomOrderFormController? controller;
|
||||||
|
|
||||||
|
/// Called after a commission has been created and the operator taps "Done".
|
||||||
|
final void Function(Order order)? onComplete;
|
||||||
|
|
||||||
|
const CustomOrderFormPage({super.key, this.repository, this.controller, this.onComplete})
|
||||||
|
: assert(
|
||||||
|
repository != null || controller != null,
|
||||||
|
'CustomOrderFormPage needs either a repository or a controller.',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomOrderFormPage> createState() => _CustomOrderFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomOrderFormPageState extends State<CustomOrderFormPage> {
|
||||||
|
late final CustomOrderFormController _controller;
|
||||||
|
|
||||||
|
/// Whether this page created the controller and must dispose it.
|
||||||
|
late final bool _ownsController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final injected = widget.controller;
|
||||||
|
if (injected != null) {
|
||||||
|
_controller = injected;
|
||||||
|
_ownsController = false;
|
||||||
|
} else {
|
||||||
|
final repository = widget.repository!;
|
||||||
|
_controller = CustomOrderFormController(
|
||||||
|
createCustomOrder: CreateCustomOrder(repository),
|
||||||
|
recordDeposit: RecordDeposit(repository),
|
||||||
|
);
|
||||||
|
_ownsController = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_ownsController) _controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final created = await _controller.submit();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final result = _controller.lastResult;
|
||||||
|
if (!created && result != null && !result.success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(result.errorMessage ?? 'Could not create the custom order.'),
|
||||||
|
backgroundColor: KcColors.danger,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addReferenceImage() async {
|
||||||
|
final result = await showReferenceImageDialog(context);
|
||||||
|
if (result == null) return;
|
||||||
|
_controller.addReferenceImageUrl(result.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickCompletionDate() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _controller.estimatedCompletionDate ?? now,
|
||||||
|
firstDate: DateTime(now.year, now.month, now.day),
|
||||||
|
lastDate: DateTime(now.year + 2, now.month, now.day),
|
||||||
|
helpText: 'Estimated completion',
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
_controller.setEstimatedCompletionDate(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final created = _controller.createdOrder;
|
||||||
|
if (created != null) {
|
||||||
|
return CustomOrderConfirmationPage(
|
||||||
|
order: created,
|
||||||
|
onNewOrder: _controller.reset,
|
||||||
|
onDone: widget.onComplete == null ? null : () => widget.onComplete!(created),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildForm(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForm(BuildContext context) {
|
||||||
|
final busy = _controller.isSubmitting;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(KcSpacing.lg),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Customer ─────────────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Customer'),
|
||||||
|
_FormField(
|
||||||
|
label: 'Customer Name',
|
||||||
|
hint: 'e.g. Sarah Mitchell',
|
||||||
|
enabled: !busy,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.customerName),
|
||||||
|
onChanged: _controller.setCustomerName,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Email',
|
||||||
|
hint: 'e.g. sarah@example.com',
|
||||||
|
enabled: !busy,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.customerEmail),
|
||||||
|
onChanged: _controller.setCustomerEmail,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Phone (optional)',
|
||||||
|
hint: 'e.g. 555-0100',
|
||||||
|
enabled: !busy,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.customerPhone),
|
||||||
|
onChanged: _controller.setCustomerPhone,
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
|
// ── Piece ────────────────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Commission'),
|
||||||
|
_FormField(
|
||||||
|
label: 'Description',
|
||||||
|
hint: 'e.g. Macramé wall hanging, custom size',
|
||||||
|
enabled: !busy,
|
||||||
|
maxLines: 2,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.description),
|
||||||
|
onChanged: _controller.setDescription,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Material',
|
||||||
|
hint: 'e.g. Organic cotton',
|
||||||
|
enabled: !busy,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.material),
|
||||||
|
onChanged: _controller.setMaterial,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Colour',
|
||||||
|
hint: 'e.g. Sage green',
|
||||||
|
enabled: !busy,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.color),
|
||||||
|
onChanged: _controller.setColor,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Size',
|
||||||
|
hint: 'e.g. Large',
|
||||||
|
enabled: !busy,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.size),
|
||||||
|
onChanged: _controller.setSize,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Dimensions',
|
||||||
|
hint: 'e.g. 24" x 36"',
|
||||||
|
enabled: !busy,
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.dimensions),
|
||||||
|
onChanged: _controller.setDimensions,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Special Instructions',
|
||||||
|
hint: 'e.g. Gift for an anniversary; initials on the back',
|
||||||
|
enabled: !busy,
|
||||||
|
maxLines: 3,
|
||||||
|
onChanged: _controller.setSpecialInstructions,
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
|
// ── Reference images ─────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Reference Images'),
|
||||||
|
_ReferenceImageList(
|
||||||
|
urls: _controller.referenceImageUrls,
|
||||||
|
enabled: !busy,
|
||||||
|
onAdd: _addReferenceImage,
|
||||||
|
onRemove: _controller.removeReferenceImageUrl,
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
|
// ── Pricing ──────────────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Pricing'),
|
||||||
|
_buildPricing(context, busy: busy),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
|
// ── Scheduling ───────────────────────────────────────────────────
|
||||||
|
const KcSectionHeader(title: 'Scheduling'),
|
||||||
|
_buildScheduling(context, busy: busy),
|
||||||
|
const SizedBox(height: KcSpacing.xl),
|
||||||
|
|
||||||
|
// ── Submit ───────────────────────────────────────────────────────
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: busy ? null : _submit,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.add_task),
|
||||||
|
label: Text(busy ? 'Creating…' : 'Create Custom Order'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPricing(BuildContext context, {required bool busy}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final balance = _controller.balanceDueCents;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_FormField(
|
||||||
|
label: 'Estimated Price',
|
||||||
|
hint: 'e.g. 72.00',
|
||||||
|
enabled: !busy,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.estimatedPrice),
|
||||||
|
onChanged: _controller.setEstimatedPrice,
|
||||||
|
),
|
||||||
|
_FormField(
|
||||||
|
label: 'Deposit Amount',
|
||||||
|
hint: 'e.g. 24.00',
|
||||||
|
enabled: !busy,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
errorText: _controller.errorFor(CustomOrderField.depositAmount),
|
||||||
|
onChanged: _controller.setDepositAmount,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Deposit collected now'),
|
||||||
|
subtitle: const Text('Records the deposit against the order immediately.'),
|
||||||
|
value: _controller.depositCollected,
|
||||||
|
onChanged: busy ? null : _controller.setDepositCollected,
|
||||||
|
),
|
||||||
|
if (_controller.depositCollected) ...[
|
||||||
|
const SizedBox(height: KcSpacing.xs),
|
||||||
|
SegmentedButton<PaymentMethod>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: PaymentMethod.cash,
|
||||||
|
label: Text('Cash'),
|
||||||
|
icon: Icon(Icons.payments_outlined, size: 16),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: PaymentMethod.card,
|
||||||
|
label: Text('Card'),
|
||||||
|
icon: Icon(Icons.credit_card, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_controller.depositMethod},
|
||||||
|
onSelectionChanged: busy
|
||||||
|
? null
|
||||||
|
: (selection) => _controller.setDepositMethod(selection.first),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (balance != null) ...[
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
Text(
|
||||||
|
'Balance due: ${DepositInfo.formatCents(balance)}',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: KcColors.deepTeal,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScheduling(BuildContext context, {required bool busy}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final date = _controller.estimatedCompletionDate;
|
||||||
|
final error = _controller.errorFor(CustomOrderField.estimatedCompletionDate);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
date == null
|
||||||
|
? 'No estimated completion date set'
|
||||||
|
: 'Estimated completion: ${CustomOrderConfirmationPage.formatDate(date)}',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: busy ? null : _pickCompletionDate,
|
||||||
|
icon: const Icon(Icons.event_outlined),
|
||||||
|
label: const Text('Pick Date'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: KcSpacing.xs),
|
||||||
|
child: Text(error, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.danger)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
SegmentedButton<OrderSource>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: OrderSource.market, label: Text('Market')),
|
||||||
|
ButtonSegment(value: OrderSource.manual, label: Text('Manual')),
|
||||||
|
ButtonSegment(value: OrderSource.online, label: Text('Online')),
|
||||||
|
],
|
||||||
|
selected: {_controller.source},
|
||||||
|
onSelectionChanged: busy ? null : (selection) => _controller.setSource(selection.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
_FormField(
|
||||||
|
label: 'Handover / Shipping Address',
|
||||||
|
hint: 'Leave blank for customer pickup',
|
||||||
|
enabled: !busy,
|
||||||
|
maxLines: 2,
|
||||||
|
onChanged: _controller.setShippingAddress,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A labelled text input used throughout the intake form.
|
||||||
|
///
|
||||||
|
/// Uses an uncontrolled [TextField] — the controller owns the value, so the
|
||||||
|
/// field reports changes upward rather than being driven from above. This
|
||||||
|
/// keeps the cursor stable while the operator types.
|
||||||
|
class _FormField extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool enabled;
|
||||||
|
final int maxLines;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final String? errorText;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
const _FormField({
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onChanged,
|
||||||
|
this.maxLines = 1,
|
||||||
|
this.keyboardType,
|
||||||
|
this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: KcSpacing.md),
|
||||||
|
child: TextField(
|
||||||
|
enabled: enabled,
|
||||||
|
maxLines: maxLines,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: errorText,
|
||||||
|
alignLabelWithHint: maxLines > 1,
|
||||||
|
),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reference image list with an "Add Image" affordance.
|
||||||
|
///
|
||||||
|
/// Shows the captured URLs as removable chips. Mobile targets meet the 48dp
|
||||||
|
/// minimum through the [OutlinedButton] and the chip delete handles.
|
||||||
|
class _ReferenceImageList extends StatelessWidget {
|
||||||
|
final List<String> urls;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
final ValueChanged<String> onRemove;
|
||||||
|
|
||||||
|
const _ReferenceImageList({
|
||||||
|
required this.urls,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onAdd,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (urls.isEmpty)
|
||||||
|
Text(
|
||||||
|
'No reference images attached.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: KcSpacing.sm,
|
||||||
|
runSpacing: KcSpacing.sm,
|
||||||
|
children: urls
|
||||||
|
.map(
|
||||||
|
(url) => InputChip(
|
||||||
|
label: Text(url, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
avatar: const Icon(Icons.image_outlined, size: 18),
|
||||||
|
onDeleted: enabled ? () => onRemove(url) : null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: enabled ? onAdd : null,
|
||||||
|
icon: const Icon(Icons.add_a_photo_outlined),
|
||||||
|
label: const Text('Add Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,12 @@ class OrdersPage extends StatefulWidget {
|
||||||
/// Optional callback to navigate to the Inventory page for a given SKU.
|
/// Optional callback to navigate to the Inventory page for a given SKU.
|
||||||
final void Function(String sku)? onViewInventory;
|
final void Function(String sku)? onViewInventory;
|
||||||
|
|
||||||
|
/// Optional callback that opens the custom order intake form.
|
||||||
|
///
|
||||||
|
/// When `null`, the "New Custom Order" action is not rendered — the page
|
||||||
|
/// stays usable in contexts that have no route for the intake flow.
|
||||||
|
final VoidCallback? onCreateCustomOrder;
|
||||||
|
|
||||||
/// Optional initial status filter to apply on first load (e.g. `'pending'`).
|
/// Optional initial status filter to apply on first load (e.g. `'pending'`).
|
||||||
final String? initialFilter;
|
final String? initialFilter;
|
||||||
|
|
||||||
|
|
@ -40,6 +46,7 @@ class OrdersPage extends StatefulWidget {
|
||||||
required this.repository,
|
required this.repository,
|
||||||
this.onViewProduct,
|
this.onViewProduct,
|
||||||
this.onViewInventory,
|
this.onViewInventory,
|
||||||
|
this.onCreateCustomOrder,
|
||||||
this.initialFilter,
|
this.initialFilter,
|
||||||
this.initialQuery,
|
this.initialQuery,
|
||||||
this.initialSelectedId,
|
this.initialSelectedId,
|
||||||
|
|
@ -137,6 +144,7 @@ class _OrdersPageState extends State<OrdersPage> {
|
||||||
orderCount: _controller.orders.length,
|
orderCount: _controller.orders.length,
|
||||||
onSearchChanged: _controller.setSearchQuery,
|
onSearchChanged: _controller.setSearchQuery,
|
||||||
onFilterChanged: _controller.setFilter,
|
onFilterChanged: _controller.setFilter,
|
||||||
|
onCreateCustomOrder: widget.onCreateCustomOrder,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isWide
|
child: isWide
|
||||||
|
|
@ -172,6 +180,7 @@ class _OrdersToolbar extends StatelessWidget {
|
||||||
final int orderCount;
|
final int orderCount;
|
||||||
final ValueChanged<String> onSearchChanged;
|
final ValueChanged<String> onSearchChanged;
|
||||||
final ValueChanged<String?> onFilterChanged;
|
final ValueChanged<String?> onFilterChanged;
|
||||||
|
final VoidCallback? onCreateCustomOrder;
|
||||||
|
|
||||||
const _OrdersToolbar({
|
const _OrdersToolbar({
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
|
|
@ -179,6 +188,7 @@ class _OrdersToolbar extends StatelessWidget {
|
||||||
required this.orderCount,
|
required this.orderCount,
|
||||||
required this.onSearchChanged,
|
required this.onSearchChanged,
|
||||||
required this.onFilterChanged,
|
required this.onFilterChanged,
|
||||||
|
this.onCreateCustomOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _filters = [
|
static const _filters = [
|
||||||
|
|
@ -197,6 +207,19 @@ class _OrdersToolbar extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// New custom order action
|
||||||
|
if (onCreateCustomOrder != null) ...[
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onCreateCustomOrder,
|
||||||
|
icon: const Icon(Icons.design_services_outlined),
|
||||||
|
label: const Text('New Custom Order'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
],
|
||||||
|
|
||||||
// Search bar
|
// Search bar
|
||||||
TextField(
|
TextField(
|
||||||
controller: searchController,
|
controller: searchController,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../domain/fulfillment_status.dart';
|
||||||
|
|
||||||
|
/// A chip that displays the [FulfillmentStatus] of a custom order using the
|
||||||
|
/// design-system [KcStatusChip].
|
||||||
|
///
|
||||||
|
/// This is distinct from `OrderStatusChip`, which shows the commercial
|
||||||
|
/// `OrderStatus`. A custom order carries both.
|
||||||
|
class FulfillmentStatusChip extends StatelessWidget {
|
||||||
|
final FulfillmentStatus status;
|
||||||
|
|
||||||
|
const FulfillmentStatusChip({super.key, required this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final (bg, fg) = _style(status);
|
||||||
|
return KcStatusChip(label: status.label, background: bg, foreground: fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static (Color, Color) _style(FulfillmentStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case FulfillmentStatus.quoted:
|
||||||
|
return (const Color(0xFFFFF8E1), KcColors.warning);
|
||||||
|
case FulfillmentStatus.depositReceived:
|
||||||
|
return (const Color(0xFFE3F2FD), KcColors.denimBlue);
|
||||||
|
case FulfillmentStatus.inProduction:
|
||||||
|
return (const Color(0xFFE0F7FA), KcColors.deepTeal);
|
||||||
|
case FulfillmentStatus.qualityCheck:
|
||||||
|
return (const Color(0xFFF3E5F5), const Color(0xFF7B1FA2));
|
||||||
|
case FulfillmentStatus.readyForPickup:
|
||||||
|
return (const Color(0xFFE8F5E9), KcColors.success);
|
||||||
|
case FulfillmentStatus.completed:
|
||||||
|
return (const Color(0xFFE8F5E9), KcColors.success);
|
||||||
|
case FulfillmentStatus.cancelled:
|
||||||
|
return (const Color(0xFFFFEBEE), KcColors.danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// The result returned by [showReferenceImageDialog].
|
||||||
|
class ReferenceImageDialogResult {
|
||||||
|
/// The trimmed URL of the reference image.
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const ReferenceImageDialogResult({required this.url});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a modal dialog that captures a reference image URL.
|
||||||
|
///
|
||||||
|
/// The platform picker (camera on mobile, file picker on web) uploads the
|
||||||
|
/// image and yields a URL; this dialog is the manual entry path and the
|
||||||
|
/// fallback used in fake mode. Raw image bytes never enter the domain — only
|
||||||
|
/// references to already-uploaded media do.
|
||||||
|
///
|
||||||
|
/// Returns a [ReferenceImageDialogResult] when the operator confirms, or
|
||||||
|
/// `null` if they cancel.
|
||||||
|
Future<ReferenceImageDialogResult?> showReferenceImageDialog(BuildContext context) {
|
||||||
|
return showDialog<ReferenceImageDialogResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const _ReferenceImageDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReferenceImageDialog extends StatefulWidget {
|
||||||
|
const _ReferenceImageDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReferenceImageDialog> createState() => _ReferenceImageDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReferenceImageDialogState extends State<_ReferenceImageDialog> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
setState(() => _error = 'Enter an image URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final uri = Uri.tryParse(text);
|
||||||
|
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
|
||||||
|
setState(() => _error = 'Enter a full URL (e.g. https://…).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(ReferenceImageDialogResult(url: text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Add Reference Image'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Paste the URL of an uploaded reference image.'),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'https://example.com/reference.jpg',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _error,
|
||||||
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_error != null) setState(() => _error = null);
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||||
|
FilledButton(onPressed: _submit, child: const Text('Add Image')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,738 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
|
||||||
|
/// A repository that fails every custom-order write, used to prove the
|
||||||
|
/// controller surfaces failures instead of swallowing them.
|
||||||
|
class _FailingOrdersRepository extends FakeOrdersRepository {
|
||||||
|
@override
|
||||||
|
Future<OrderActionResult> createCustomOrder(
|
||||||
|
Order order, {
|
||||||
|
required CustomOrderSpec spec,
|
||||||
|
DepositInfo? deposit,
|
||||||
|
}) async => const OrderActionResult.failure('Backend unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A repository whose deposit recording fails while creation succeeds.
|
||||||
|
class _DepositFailingRepository extends FakeOrdersRepository {
|
||||||
|
@override
|
||||||
|
Future<OrderActionResult> recordDeposit(
|
||||||
|
String id, {
|
||||||
|
required int amountCents,
|
||||||
|
required DateTime paidAt,
|
||||||
|
int? totalAmountCents,
|
||||||
|
}) async => const OrderActionResult.failure('Deposit could not be recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final fixedNow = DateTime(2026, 9, 25, 10, 30);
|
||||||
|
|
||||||
|
CustomOrderFormController build(
|
||||||
|
OrdersRepository repository, {
|
||||||
|
bool withDeposit = true,
|
||||||
|
String id = 'KC-C7001',
|
||||||
|
}) {
|
||||||
|
return CustomOrderFormController(
|
||||||
|
createCustomOrder: CreateCustomOrder(repository),
|
||||||
|
recordDeposit: withDeposit ? RecordDeposit(repository) : null,
|
||||||
|
now: () => fixedNow,
|
||||||
|
generateId: () => id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fillValidForm(CustomOrderFormController controller) {
|
||||||
|
controller.setCustomerName('Sarah Mitchell');
|
||||||
|
controller.setCustomerEmail('sarah@example.com');
|
||||||
|
controller.setDescription('Macramé wall hanging');
|
||||||
|
controller.setMaterial('Organic cotton');
|
||||||
|
controller.setColor('Sage green');
|
||||||
|
controller.setSize('Large');
|
||||||
|
controller.setEstimatedPrice('72.00');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Money parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController.parseMoneyToCents', () {
|
||||||
|
test('parses whole dollars', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('72'), 7200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses two decimal places', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('72.35'), 7235);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pads a single decimal place', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('72.5'), 7250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips currency symbol, separators, and whitespace', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents(r' $1,299.50 '), 129950);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for empty input', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents(' '), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for non-money text', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('twelve'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for more than two decimal places', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('12.345'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for a negative amount', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('-12.00'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses zero', () {
|
||||||
|
expect(CustomOrderFormController.parseMoneyToCents('0'), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initial state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — initial state', () {
|
||||||
|
late CustomOrderFormController controller;
|
||||||
|
|
||||||
|
setUp(() => controller = build(FakeOrdersRepository()));
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
test('starts empty and not submitting', () {
|
||||||
|
expect(controller.customerName, '');
|
||||||
|
expect(controller.customerEmail, '');
|
||||||
|
expect(controller.material, '');
|
||||||
|
expect(controller.isSubmitting, false);
|
||||||
|
expect(controller.errors, isEmpty);
|
||||||
|
expect(controller.lastResult, isNull);
|
||||||
|
expect(controller.createdOrder, isNull);
|
||||||
|
expect(controller.isComplete, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults to a market intake with a card deposit', () {
|
||||||
|
expect(controller.source, OrderSource.market);
|
||||||
|
expect(controller.depositMethod, PaymentMethod.card);
|
||||||
|
expect(controller.depositCollected, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has no reference images', () {
|
||||||
|
expect(controller.referenceImageUrls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('derived money getters are null before input', () {
|
||||||
|
expect(controller.estimatedPriceCents, isNull);
|
||||||
|
expect(controller.depositAmountCents, isNull);
|
||||||
|
expect(controller.balanceDueCents, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Field setters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — field setters', () {
|
||||||
|
late CustomOrderFormController controller;
|
||||||
|
late int notifications;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
controller = build(FakeOrdersRepository());
|
||||||
|
notifications = 0;
|
||||||
|
controller.addListener(() => notifications++);
|
||||||
|
});
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
test('setCustomerName stores the value and notifies', () {
|
||||||
|
controller.setCustomerName('Sarah Mitchell');
|
||||||
|
expect(controller.customerName, 'Sarah Mitchell');
|
||||||
|
expect(notifications, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setEstimatedPrice updates derived cents', () {
|
||||||
|
controller.setEstimatedPrice('72.00');
|
||||||
|
expect(controller.estimatedPriceCents, 7200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDepositAmount updates derived cents', () {
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
expect(controller.depositAmountCents, 2400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('balanceDueCents subtracts the deposit', () {
|
||||||
|
controller.setEstimatedPrice('72.00');
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
expect(controller.balanceDueCents, 4800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('balanceDueCents clamps at zero for an over-collected deposit', () {
|
||||||
|
controller.setEstimatedPrice('20.00');
|
||||||
|
controller.setDepositAmount('50.00');
|
||||||
|
expect(controller.balanceDueCents, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('balanceDueCents equals the total when no deposit is entered', () {
|
||||||
|
controller.setEstimatedPrice('72.00');
|
||||||
|
expect(controller.balanceDueCents, 7200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setEstimatedCompletionDate stores the date', () {
|
||||||
|
final due = DateTime(2026, 12, 1);
|
||||||
|
controller.setEstimatedCompletionDate(due);
|
||||||
|
expect(controller.estimatedCompletionDate, due);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSource and setDepositMethod store their values', () {
|
||||||
|
controller.setSource(OrderSource.manual);
|
||||||
|
controller.setDepositMethod(PaymentMethod.cash);
|
||||||
|
expect(controller.source, OrderSource.manual);
|
||||||
|
expect(controller.depositMethod, PaymentMethod.cash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Reference images ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — reference images', () {
|
||||||
|
late CustomOrderFormController controller;
|
||||||
|
|
||||||
|
setUp(() => controller = build(FakeOrdersRepository()));
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
test('adds a URL', () {
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
expect(controller.referenceImageUrls, ['https://example.com/a.jpg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trims whitespace', () {
|
||||||
|
controller.addReferenceImageUrl(' https://example.com/a.jpg ');
|
||||||
|
expect(controller.referenceImageUrls, ['https://example.com/a.jpg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores blank input', () {
|
||||||
|
controller.addReferenceImageUrl(' ');
|
||||||
|
expect(controller.referenceImageUrls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores duplicates', () {
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
expect(controller.referenceImageUrls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a URL', () {
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
controller.removeReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
expect(controller.referenceImageUrls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing an unknown URL is a no-op', () {
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
controller.removeReferenceImageUrl('https://example.com/b.jpg');
|
||||||
|
expect(controller.referenceImageUrls.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — validation', () {
|
||||||
|
late CustomOrderFormController controller;
|
||||||
|
|
||||||
|
setUp(() => controller = build(FakeOrdersRepository()));
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
test('an empty form fails validation', () {
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty form reports every required field', () {
|
||||||
|
controller.validate();
|
||||||
|
expect(controller.errorFor(CustomOrderField.customerName), isNotNull);
|
||||||
|
expect(controller.errorFor(CustomOrderField.customerEmail), isNotNull);
|
||||||
|
expect(controller.errorFor(CustomOrderField.description), isNotNull);
|
||||||
|
expect(controller.errorFor(CustomOrderField.material), isNotNull);
|
||||||
|
expect(controller.errorFor(CustomOrderField.color), isNotNull);
|
||||||
|
expect(controller.errorFor(CustomOrderField.size), isNotNull);
|
||||||
|
expect(controller.errorFor(CustomOrderField.estimatedPrice), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fully filled form passes', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
expect(controller.errors, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whitespace-only customer name is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setCustomerName(' ');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(controller.errorFor(CustomOrderField.customerName), 'Customer name is required.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed email is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setCustomerEmail('not-an-email');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(controller.errorFor(CustomOrderField.customerEmail), 'Enter a valid email address.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone is optional', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
expect(controller.errorFor(CustomOrderField.customerPhone), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dimensions satisfy the size requirement', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setSize('');
|
||||||
|
controller.setDimensions('24" x 36"');
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing both size and dimensions flags both fields', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setSize('');
|
||||||
|
controller.setDimensions('');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(controller.errorFor(CustomOrderField.size), 'Enter a size or dimensions.');
|
||||||
|
expect(controller.errorFor(CustomOrderField.dimensions), 'Enter a size or dimensions.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric price is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setEstimatedPrice('seventy-two');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(
|
||||||
|
controller.errorFor(CustomOrderField.estimatedPrice),
|
||||||
|
'Enter a valid amount (e.g. 72.00).',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero price is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setEstimatedPrice('0');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(
|
||||||
|
controller.errorFor(CustomOrderField.estimatedPrice),
|
||||||
|
'Estimated price must be greater than zero.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deposit is optional', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
expect(controller.errorFor(CustomOrderField.depositAmount), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deposit larger than the price is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('100.00');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(
|
||||||
|
controller.errorFor(CustomOrderField.depositAmount),
|
||||||
|
'Deposit cannot exceed the estimated price.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deposit equal to the price is allowed', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('72.00');
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero deposit is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('0');
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(
|
||||||
|
controller.errorFor(CustomOrderField.depositAmount),
|
||||||
|
'Deposit must be greater than zero.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collecting a deposit without an amount is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(
|
||||||
|
controller.errorFor(CustomOrderField.depositAmount),
|
||||||
|
'Enter the deposit amount that was collected.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a past completion date is rejected', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setEstimatedCompletionDate(DateTime(2026, 9, 24));
|
||||||
|
expect(controller.validate(), false);
|
||||||
|
expect(
|
||||||
|
controller.errorFor(CustomOrderField.estimatedCompletionDate),
|
||||||
|
'Estimated completion cannot be in the past.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today is an acceptable completion date', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setEstimatedCompletionDate(DateTime(2026, 9, 25));
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a future completion date is accepted', () {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setEstimatedCompletionDate(DateTime(2026, 12, 1));
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('errors are cleared on a subsequent valid pass', () {
|
||||||
|
controller.validate();
|
||||||
|
expect(controller.errors, isNotEmpty);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.validate();
|
||||||
|
expect(controller.errors, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the published errors map is unmodifiable', () {
|
||||||
|
controller.validate();
|
||||||
|
expect(() => controller.errors[CustomOrderField.material] = 'x', throwsUnsupportedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── buildSpec ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController.buildSpec', () {
|
||||||
|
late CustomOrderFormController controller;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
controller = build(FakeOrdersRepository());
|
||||||
|
fillValidForm(controller);
|
||||||
|
});
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
test('carries the trimmed material and colour', () {
|
||||||
|
controller.setMaterial(' Organic cotton ');
|
||||||
|
controller.setColor(' Sage green ');
|
||||||
|
final spec = controller.buildSpec();
|
||||||
|
expect(spec.material, 'Organic cotton');
|
||||||
|
expect(spec.color, 'Sage green');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves an empty size null', () {
|
||||||
|
controller.setSize('');
|
||||||
|
controller.setDimensions('24" x 36"');
|
||||||
|
expect(controller.buildSpec().size, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves empty dimensions null', () {
|
||||||
|
expect(controller.buildSpec().dimensions, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps special instructions onto notes', () {
|
||||||
|
controller.setSpecialInstructions('Initials on the back');
|
||||||
|
expect(controller.buildSpec().notes, 'Initials on the back');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves empty special instructions null', () {
|
||||||
|
expect(controller.buildSpec().notes, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carries reference images', () {
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
final spec = controller.buildSpec();
|
||||||
|
expect(spec.referenceImageUrls, ['https://example.com/a.jpg']);
|
||||||
|
expect(spec.hasReferenceImages, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carries the estimated completion date', () {
|
||||||
|
final due = DateTime(2026, 12, 1);
|
||||||
|
controller.setEstimatedCompletionDate(due);
|
||||||
|
expect(controller.buildSpec().estimatedCompletionDate, due);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a spec with a size is production ready', () {
|
||||||
|
expect(controller.buildSpec().isProductionReady, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Submission ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — submit', () {
|
||||||
|
late CustomOrderFormController controller;
|
||||||
|
|
||||||
|
setUp(() => controller = build(FakeOrdersRepository()));
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
test('an invalid form does not submit', () async {
|
||||||
|
expect(await controller.submit(), false);
|
||||||
|
expect(controller.createdOrder, isNull);
|
||||||
|
expect(controller.lastResult, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid form creates a custom order', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
expect(await controller.submit(), true);
|
||||||
|
expect(controller.createdOrder, isNotNull);
|
||||||
|
expect(controller.createdOrder!.id, 'KC-C7001');
|
||||||
|
expect(controller.isComplete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the created order is stamped as a custom order', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.orderType, OrderType.custom);
|
||||||
|
expect(controller.createdOrder!.isCustom, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the created order starts at the quoted stage', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.fulfillmentStatus, FulfillmentStatus.quoted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('customer details reach the order', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setCustomerPhone('555-0100');
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final order = controller.createdOrder!;
|
||||||
|
expect(order.customerName, 'Sarah Mitchell');
|
||||||
|
expect(order.customerEmail, 'sarah@example.com');
|
||||||
|
expect(order.customerPhone, '555-0100');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty phone is stored as null', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.customerPhone, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the specification reaches the order', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final spec = controller.createdOrder!.customOrderSpec!;
|
||||||
|
expect(spec.material, 'Organic cotton');
|
||||||
|
expect(spec.color, 'Sage green');
|
||||||
|
expect(spec.size, 'Large');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the single line item carries the description and the CUSTOM sku', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final items = controller.createdOrder!.items;
|
||||||
|
expect(items.length, 1);
|
||||||
|
expect(items.first.productName, 'Macramé wall hanging');
|
||||||
|
expect(items.first.sku, CustomOrderFormController.customSku);
|
||||||
|
expect(items.first.quantity, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the line item price matches the estimated price', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.total, 72.00);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spec fields are mirrored into line item customizations', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDimensions('24" x 36"');
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final customizations = controller.createdOrder!.items.first.customizations;
|
||||||
|
expect(customizations['material'], 'Organic cotton');
|
||||||
|
expect(customizations['color'], 'Sage green');
|
||||||
|
expect(customizations['size'], 'Large');
|
||||||
|
expect(customizations['dimensions'], '24" x 36"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty handover address defaults to customer pickup', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.shippingAddress, 'Customer pickup');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a supplied handover address is preserved', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setShippingAddress('12 Market St, Testville');
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.shippingAddress, '12 Market St, Testville');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the intake source is recorded', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.source, OrderSource.market);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the injected clock stamps the order date', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.createdOrder!.orderDate, fixedNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isSubmitting is cleared after a submission', () async {
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
expect(controller.isSubmitting, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Deposit handling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — deposits', () {
|
||||||
|
test('a quoted deposit is attached without being marked paid', () async {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final deposit = controller.createdOrder!.depositInfo!;
|
||||||
|
expect(deposit.depositAmountCents, 2400);
|
||||||
|
expect(deposit.totalAmountCents, 7200);
|
||||||
|
expect(deposit.depositPaid, false);
|
||||||
|
expect(deposit.balanceDueCents, 7200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no deposit record is created when the amount is blank', () async {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
expect(controller.createdOrder!.depositInfo, isNull);
|
||||||
|
expect(controller.createdOrder!.hasDeposit, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a collected deposit is recorded against the order', () async {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final deposit = controller.createdOrder!.depositInfo!;
|
||||||
|
expect(deposit.depositPaid, true);
|
||||||
|
expect(deposit.depositDate, fixedNow);
|
||||||
|
expect(deposit.balanceDueCents, 4800);
|
||||||
|
expect(controller.createdOrder!.hasDeposit, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deposit method is recorded as the payment method', () async {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
controller.setDepositMethod(PaymentMethod.cash);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
expect(controller.createdOrder!.paymentMethod, 'Cash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no payment method is recorded when no deposit was collected', () async {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
expect(controller.createdOrder!.paymentMethod, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deposit is not recorded without a RecordDeposit use case', () async {
|
||||||
|
final controller = build(FakeOrdersRepository(), withDeposit: false);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
expect(controller.createdOrder!.depositInfo!.depositPaid, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deposit recording failure keeps the created order', () async {
|
||||||
|
final controller = build(_DepositFailingRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
|
||||||
|
expect(await controller.submit(), true);
|
||||||
|
expect(controller.createdOrder, isNotNull);
|
||||||
|
expect(controller.lastResult!.success, false);
|
||||||
|
expect(controller.lastResult!.errorMessage, 'Deposit could not be recorded.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Failure and reset ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('CustomOrderFormController — failure and reset', () {
|
||||||
|
test('a repository failure is surfaced and no order is stored', () async {
|
||||||
|
final controller = build(_FailingOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
|
||||||
|
expect(await controller.submit(), false);
|
||||||
|
expect(controller.createdOrder, isNull);
|
||||||
|
expect(controller.lastResult!.success, false);
|
||||||
|
expect(controller.lastResult!.errorMessage, 'Backend unavailable.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a duplicate order ID is rejected by the repository', () async {
|
||||||
|
final repository = FakeOrdersRepository();
|
||||||
|
final first = build(repository);
|
||||||
|
addTearDown(first.dispose);
|
||||||
|
fillValidForm(first);
|
||||||
|
await first.submit();
|
||||||
|
|
||||||
|
final second = build(repository);
|
||||||
|
addTearDown(second.dispose);
|
||||||
|
fillValidForm(second);
|
||||||
|
|
||||||
|
expect(await second.submit(), false);
|
||||||
|
expect(second.lastResult!.success, false);
|
||||||
|
expect(second.lastResult!.errorMessage, contains('already exists'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset clears every field and the submission result', () async {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
controller.addReferenceImageUrl('https://example.com/a.jpg');
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
expect(controller.customerName, '');
|
||||||
|
expect(controller.material, '');
|
||||||
|
expect(controller.estimatedPrice, '');
|
||||||
|
expect(controller.depositAmount, '');
|
||||||
|
expect(controller.depositCollected, false);
|
||||||
|
expect(controller.referenceImageUrls, isEmpty);
|
||||||
|
expect(controller.errors, isEmpty);
|
||||||
|
expect(controller.lastResult, isNull);
|
||||||
|
expect(controller.createdOrder, isNull);
|
||||||
|
expect(controller.isComplete, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset restores the default source and deposit method', () {
|
||||||
|
final controller = build(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
controller.setSource(OrderSource.manual);
|
||||||
|
controller.setDepositMethod(PaymentMethod.cash);
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
expect(controller.source, OrderSource.market);
|
||||||
|
expect(controller.depositMethod, PaymentMethod.card);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the created order is retrievable from the repository afterwards', () async {
|
||||||
|
final repository = FakeOrdersRepository();
|
||||||
|
final controller = build(repository);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
fillValidForm(controller);
|
||||||
|
await controller.submit();
|
||||||
|
|
||||||
|
final stored = await repository.getOrder('KC-C7001');
|
||||||
|
expect(stored, isNotNull);
|
||||||
|
expect(stored!.isCustom, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
|
||||||
|
Order _commission({DepositInfo? deposit, String? paymentMethod, CustomOrderSpec? spec}) {
|
||||||
|
return Order(
|
||||||
|
id: 'KC-C7001',
|
||||||
|
customerName: 'Sarah Mitchell',
|
||||||
|
customerEmail: 'sarah@example.com',
|
||||||
|
customerPhone: '555-0100',
|
||||||
|
orderDate: DateTime(2026, 9, 25),
|
||||||
|
status: OrderStatus.pending,
|
||||||
|
shippingAddress: 'Customer pickup',
|
||||||
|
items: const [
|
||||||
|
OrderItem(productName: 'Macramé wall hanging', sku: 'CUSTOM', quantity: 1, unitPrice: 72.00),
|
||||||
|
],
|
||||||
|
paymentMethod: paymentMethod,
|
||||||
|
source: OrderSource.market,
|
||||||
|
orderType: OrderType.custom,
|
||||||
|
customOrderSpec:
|
||||||
|
spec ?? CustomOrderSpec(material: 'Organic cotton', color: 'Sage green', size: 'Large'),
|
||||||
|
depositInfo: deposit,
|
||||||
|
fulfillmentStatus: FulfillmentStatus.quoted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _wrap(Order order, {VoidCallback? onNewOrder, VoidCallback? onDone}) {
|
||||||
|
return MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: CustomOrderConfirmationPage(order: order, onNewOrder: onNewOrder, onDone: onDone),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CustomOrderConfirmationPage — summary', () {
|
||||||
|
testWidgets('shows the success header and the order ID', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(_commission()));
|
||||||
|
|
||||||
|
expect(find.text('Custom Order Created'), findsOneWidget);
|
||||||
|
expect(find.text('KC-C7001'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the fulfillment stage chip', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(_commission()));
|
||||||
|
|
||||||
|
expect(find.byType(FulfillmentStatusChip), findsOneWidget);
|
||||||
|
expect(find.text('Quoted'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the customer details', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(_commission()));
|
||||||
|
|
||||||
|
expect(find.text('Sarah Mitchell'), findsOneWidget);
|
||||||
|
expect(find.text('sarah@example.com'), findsOneWidget);
|
||||||
|
expect(find.text('555-0100'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the description and specification', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(_commission()));
|
||||||
|
|
||||||
|
expect(find.text('Macramé wall hanging'), findsOneWidget);
|
||||||
|
expect(find.text('Organic cotton'), findsOneWidget);
|
||||||
|
expect(find.text('Sage green'), findsOneWidget);
|
||||||
|
expect(find.text('Large'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows dimensions when supplied', (tester) async {
|
||||||
|
final spec = CustomOrderSpec(material: 'Slate', color: 'Charcoal', dimensions: '24" x 36"');
|
||||||
|
await tester.pumpWidget(_wrap(_commission(spec: spec)));
|
||||||
|
|
||||||
|
expect(find.text('24" x 36"'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the estimated completion date when scheduled', (tester) async {
|
||||||
|
final spec = CustomOrderSpec(
|
||||||
|
material: 'Organic cotton',
|
||||||
|
color: 'Sage green',
|
||||||
|
size: 'Large',
|
||||||
|
estimatedCompletionDate: DateTime(2026, 12, 1),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(spec: spec)));
|
||||||
|
|
||||||
|
expect(find.text('2026-12-01'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the reference image count', (tester) async {
|
||||||
|
final spec = CustomOrderSpec(
|
||||||
|
material: 'Organic cotton',
|
||||||
|
color: 'Sage green',
|
||||||
|
size: 'Large',
|
||||||
|
referenceImageUrls: const ['https://example.com/a.jpg', 'https://example.com/b.jpg'],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(spec: spec)));
|
||||||
|
|
||||||
|
expect(find.text('2 attached'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CustomOrderConfirmationPage — deposit receipt', () {
|
||||||
|
testWidgets('no receipt is rendered without a deposit', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(_commission()));
|
||||||
|
|
||||||
|
expect(find.text('Deposit Receipt'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a quoted deposit renders as due, not paid', (tester) async {
|
||||||
|
const deposit = DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(deposit: deposit)));
|
||||||
|
|
||||||
|
expect(find.text('Deposit Receipt'), findsOneWidget);
|
||||||
|
expect(find.text('Deposit Due'), findsOneWidget);
|
||||||
|
expect(find.text('Deposit Paid'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the agreed total and the deposit amount', (tester) async {
|
||||||
|
const deposit = DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(deposit: deposit)));
|
||||||
|
|
||||||
|
// The agreed total and the balance due are both $72.00 until the
|
||||||
|
// deposit is actually collected.
|
||||||
|
expect(find.text('\$72.00'), findsNWidgets(2));
|
||||||
|
expect(find.text('\$24.00'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a collected deposit renders as paid with its date', (tester) async {
|
||||||
|
final deposit = DepositInfo(
|
||||||
|
depositAmountCents: 2400,
|
||||||
|
totalAmountCents: 7200,
|
||||||
|
depositDate: DateTime(2026, 9, 25),
|
||||||
|
depositPaid: true,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(deposit: deposit, paymentMethod: 'Card')));
|
||||||
|
|
||||||
|
expect(find.text('Deposit Paid'), findsOneWidget);
|
||||||
|
expect(find.text('2026-09-25'), findsOneWidget);
|
||||||
|
expect(find.text('Card'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the balance reflects a collected deposit', (tester) async {
|
||||||
|
final deposit = DepositInfo(
|
||||||
|
depositAmountCents: 2400,
|
||||||
|
totalAmountCents: 7200,
|
||||||
|
depositDate: DateTime(2026, 9, 25),
|
||||||
|
depositPaid: true,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(deposit: deposit)));
|
||||||
|
|
||||||
|
expect(find.text('\$48.00'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the receipt never renders a payment identifier', (tester) async {
|
||||||
|
final deposit = DepositInfo(
|
||||||
|
depositAmountCents: 2400,
|
||||||
|
totalAmountCents: 7200,
|
||||||
|
depositDate: DateTime(2026, 9, 25),
|
||||||
|
depositPaid: true,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_wrap(_commission(deposit: deposit, paymentMethod: 'Card')));
|
||||||
|
|
||||||
|
expect(find.textContaining('txn'), findsNothing);
|
||||||
|
expect(find.textContaining('Transaction'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CustomOrderConfirmationPage — actions', () {
|
||||||
|
testWidgets('neither action is rendered without callbacks', (tester) async {
|
||||||
|
await tester.pumpWidget(_wrap(_commission()));
|
||||||
|
|
||||||
|
expect(find.text('New Custom Order'), findsNothing);
|
||||||
|
expect(find.text('Done'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('New Custom Order invokes its callback', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(_wrap(_commission(), onNewOrder: () => tapped = true));
|
||||||
|
|
||||||
|
await tester.tap(find.text('New Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tapped, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Done invokes its callback', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(_wrap(_commission(), onDone: () => tapped = true));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Done'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tapped, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FulfillmentStatusChip', () {
|
||||||
|
Widget chip(FulfillmentStatus status) => MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(child: FulfillmentStatusChip(status: status)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final status in FulfillmentStatus.values) {
|
||||||
|
testWidgets('renders the ${status.name} label', (tester) async {
|
||||||
|
await tester.pumpWidget(chip(status));
|
||||||
|
expect(find.text(status.label), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('delegates rendering to KcStatusChip', (tester) async {
|
||||||
|
await tester.pumpWidget(chip(FulfillmentStatus.inProduction));
|
||||||
|
expect(find.byType(KcStatusChip), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
|
|
||||||
|
Widget _wrap(Widget child) => MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Pumps [child] onto a tall test surface.
|
||||||
|
///
|
||||||
|
/// The intake form is long by design; the default 800×600 test view puts the
|
||||||
|
/// submit button outside the hit-test area, which is a harness artefact rather
|
||||||
|
/// than a layout defect — on a real device the page scrolls.
|
||||||
|
Future<void> _pumpPage(WidgetTester tester, Widget child) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 4000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(_wrap(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomOrderFormController _controller(OrdersRepository repository, {String id = 'KC-C7001'}) {
|
||||||
|
return CustomOrderFormController(
|
||||||
|
createCustomOrder: CreateCustomOrder(repository),
|
||||||
|
recordDeposit: RecordDeposit(repository),
|
||||||
|
now: () => DateTime(2026, 9, 25, 10, 30),
|
||||||
|
generateId: () => id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills the required inputs by index into the page's [TextField] list.
|
||||||
|
///
|
||||||
|
/// Field order: name, email, phone, description, material, colour, size,
|
||||||
|
/// dimensions, special instructions, estimated price, deposit, handover.
|
||||||
|
Future<void> _fillRequired(WidgetTester tester) async {
|
||||||
|
final fields = find.byType(TextField);
|
||||||
|
await tester.enterText(fields.at(0), 'Sarah Mitchell');
|
||||||
|
await tester.enterText(fields.at(1), 'sarah@example.com');
|
||||||
|
await tester.enterText(fields.at(3), 'Macramé wall hanging');
|
||||||
|
await tester.enterText(fields.at(4), 'Organic cotton');
|
||||||
|
await tester.enterText(fields.at(5), 'Sage green');
|
||||||
|
await tester.enterText(fields.at(6), 'Large');
|
||||||
|
await tester.enterText(fields.at(9), '72.00');
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CustomOrderFormPage — layout', () {
|
||||||
|
testWidgets('renders every section header', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('Customer'), findsOneWidget);
|
||||||
|
expect(find.text('Commission'), findsOneWidget);
|
||||||
|
expect(find.text('Reference Images'), findsOneWidget);
|
||||||
|
expect(find.text('Pricing'), findsOneWidget);
|
||||||
|
expect(find.text('Scheduling'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the customer inputs', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('Customer Name'), findsOneWidget);
|
||||||
|
expect(find.text('Email'), findsOneWidget);
|
||||||
|
expect(find.text('Phone (optional)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the specification inputs', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('Description'), findsOneWidget);
|
||||||
|
expect(find.text('Material'), findsOneWidget);
|
||||||
|
expect(find.text('Colour'), findsOneWidget);
|
||||||
|
expect(find.text('Size'), findsOneWidget);
|
||||||
|
expect(find.text('Dimensions'), findsOneWidget);
|
||||||
|
expect(find.text('Special Instructions'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the pricing inputs', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('Estimated Price'), findsOneWidget);
|
||||||
|
expect(find.text('Deposit Amount'), findsOneWidget);
|
||||||
|
expect(find.text('Deposit collected now'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the empty reference image state', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('No reference images attached.'), findsOneWidget);
|
||||||
|
expect(find.text('Add Image'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows no completion date by default', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('No estimated completion date set'), findsOneWidget);
|
||||||
|
expect(find.text('Pick Date'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the submit button', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
expect(find.text('Create Custom Order'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CustomOrderFormPage — validation feedback', () {
|
||||||
|
testWidgets('submitting an empty form shows required-field errors', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Customer name is required.'), findsOneWidget);
|
||||||
|
expect(find.text('Customer email is required.'), findsOneWidget);
|
||||||
|
expect(find.text('Material is required.'), findsOneWidget);
|
||||||
|
expect(find.text('Colour is required.'), findsOneWidget);
|
||||||
|
expect(find.text('Estimated price is required.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('submitting an empty form flags size and dimensions together', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Enter a size or dimensions.'), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a malformed email is reported on the email field', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), 'not-an-email');
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Enter a valid email address.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an invalid price is reported on the price field', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).at(9), 'seventy-two');
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Enter a valid amount (e.g. 72.00).'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a valid form reports no validation errors', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await _fillRequired(tester);
|
||||||
|
|
||||||
|
expect(controller.validate(), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CustomOrderFormPage — pricing feedback', () {
|
||||||
|
testWidgets('shows the running balance once a price is entered', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
controller.setEstimatedPrice('72.00');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Balance due: \$72.00'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the balance reflects the deposit', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
controller.setEstimatedPrice('72.00');
|
||||||
|
controller.setDepositAmount('24.00');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Balance due: \$48.00'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the deposit method selector appears once a deposit is collected', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
expect(find.byType(SegmentedButton<PaymentMethod>), findsNothing);
|
||||||
|
|
||||||
|
controller.setDepositCollected(true);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(SegmentedButton<PaymentMethod>), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the estimated completion date after it is picked', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
controller.setEstimatedCompletionDate(DateTime(2026, 12, 1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Estimated completion: 2026-12-01'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CustomOrderFormPage — reference images', () {
|
||||||
|
testWidgets('Add Image opens the reference image dialog', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add Image'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(find.text('Add Reference Image'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an empty URL is rejected by the dialog', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add Image'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add Image'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Enter an image URL.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a non-URL is rejected by the dialog', (tester) async {
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(repository: FakeOrdersRepository()));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add Image'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'reference.jpg');
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add Image'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Enter a full URL (e.g. https://…).'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a confirmed URL becomes a removable chip', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await tester.tap(find.text('Add Image'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'https://example.com/a.jpg');
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add Image'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(controller.referenceImageUrls, ['https://example.com/a.jpg']);
|
||||||
|
expect(find.byType(InputChip), findsOneWidget);
|
||||||
|
expect(find.text('No reference images attached.'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cancelling the dialog adds nothing', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await tester.tap(find.text('Add Image'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(controller.referenceImageUrls, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CustomOrderFormPage — submission', () {
|
||||||
|
testWidgets('a successful submission swaps to the confirmation page', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await _fillRequired(tester);
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Custom Order Created'), findsOneWidget);
|
||||||
|
expect(find.text('KC-C7001'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the confirmation page shows the captured specification', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await _fillRequired(tester);
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Organic cotton'), findsOneWidget);
|
||||||
|
expect(find.text('Sage green'), findsOneWidget);
|
||||||
|
expect(find.text('Sarah Mitchell'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('New Custom Order returns to an empty form', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await _fillRequired(tester);
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('New Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Create Custom Order'), findsOneWidget);
|
||||||
|
expect(controller.customerName, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Done reports the created order through onComplete', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
Order? completed;
|
||||||
|
|
||||||
|
await _pumpPage(
|
||||||
|
tester,
|
||||||
|
CustomOrderFormPage(controller: controller, onComplete: (o) => completed = o),
|
||||||
|
);
|
||||||
|
await _fillRequired(tester);
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Done'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(completed, isNotNull);
|
||||||
|
expect(completed!.id, 'KC-C7001');
|
||||||
|
expect(completed!.isCustom, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Done is not offered when no onComplete callback is supplied', (tester) async {
|
||||||
|
final controller = _controller(FakeOrdersRepository());
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await _pumpPage(tester, CustomOrderFormPage(controller: controller));
|
||||||
|
await _fillRequired(tester);
|
||||||
|
await tester.tap(find.text('Create Custom Order'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Done'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -51,10 +51,12 @@ Widget _buildPanel(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPage(OrdersRepository repository) {
|
Widget _buildPage(OrdersRepository repository, {VoidCallback? onCreateCustomOrder}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
theme: buildKcTheme(),
|
theme: buildKcTheme(),
|
||||||
home: Scaffold(body: OrdersPage(repository: repository)),
|
home: Scaffold(
|
||||||
|
body: OrdersPage(repository: repository, onCreateCustomOrder: onCreateCustomOrder),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,4 +325,35 @@ void main() {
|
||||||
expect(find.text('No orders found. Try adjusting your search or filter.'), findsOneWidget);
|
expect(find.text('No orders found. Try adjusting your search or filter.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Custom order entry point (Stage 11B) ───────────────────────────────────
|
||||||
|
|
||||||
|
group('OrdersPage — custom order entry point', () {
|
||||||
|
testWidgets('no action is rendered without an onCreateCustomOrder callback', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage(FakeOrdersRepository()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('New Custom Order'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the action is rendered when a callback is supplied', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildPage(FakeOrdersRepository(), onCreateCustomOrder: () {}));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('New Custom Order'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the action invokes the callback', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildPage(FakeOrdersRepository(), onCreateCustomOrder: () => tapped = true),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('New Custom Order'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tapped, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue