Merge feat/custom-order-ui: Stage 11B custom order intake UI (2044/2044 tests passing)
Publish Docs / publish-docs (push) Successful in 53s Details

This commit is contained in:
Mike Kell 2026-09-25 15:45:29 -04:00
commit 6c299e8261
24 changed files with 3064 additions and 49 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [
Column(
children: [ children: [
_buildSearchBar(), _buildSearchBar(),
_buildFilterChips(), _buildFilterChips(),
_buildOrderCount(), _buildOrderCount(),
Expanded(child: _buildOrderList()), 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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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