feat(stage-11b): custom order intake UI

Adds the operator-facing intake flow for made-to-order commissions on top of
the Stage 11A domain and data layers. The form, validation, and confirmation
receipt all live in feature_orders, so web and mobile host the same widget
rather than forking business logic per platform.

Stage 11C still covers the tracking page, the status-change timeline, and
inventory deduction on completion.

Application:

- CreateCustomOrder and RecordDeposit use cases wrapping the repository
  methods added in Stage 11A, so presentation never assembles a custom order
  aggregate by hand. RecordDeposit deliberately accepts no payment identifier.
- CustomOrderFormController with field-keyed validation, so each input renders
  its own error rather than a single form-level message. Clock and ID
  generator are injected, which keeps created-at stamps and order IDs
  deterministic under test.
- All money is typed as text and converted to integer cents by
  parseMoneyToCents before it reaches DepositInfo, consistent with
  PaymentResult.amountCents. Thousands separators, currency symbols, excess
  precision, and negatives are rejected or normalised at the boundary. The
  only double is the derived OrderItem.unitPrice, computed once from cents.
- Derived balance clamps at zero rather than reporting a credit when a deposit
  exceeds the quoted total, matching DepositInfo.balanceDueCents.

Presentation:

- CustomOrderFormPage — sectioned intake (customer, specification, pricing and
  deposit, reference images) over the shared design-system widgets.
- CustomOrderConfirmationPage — order summary, spec rendering, and a deposit
  receipt showing amount, method, date, and balance only.
- FulfillmentStatusChip over the design-system KcStatusChip, covering all
  seven pipeline values.
- showReferenceImageDialog() plus ReferenceImageDialogResult with URL
  validation and de-duplication.
- OrdersPage gains an optional onCreateCustomOrder callback. When null the
  "New Custom Order" action is not rendered, so the page stays usable in hosts
  that have no intake route.

Hosts:

- kell_web: new /orders/custom route rendering CustomOrderFormPage inside
  AppShell, with ordersToCustomOrder() and customOrderToOrders() handoffs on
  AppNavigation. Confirmation returns to Orders pre-selecting the new order.
- kell_mobile: new MobileCustomOrderPage, a full-screen SafeArea host for the
  shared form, reached from an extended FAB on the orders list. Market intake
  is the primary custom-order use case, so mobile gets a dedicated route
  rather than an inline panel. The list reloads on return.

Privacy:

- The deposit receipt renders amount, method, date, and balance only. The
  transaction ID stays on PaymentResult and is asserted absent from the
  rendered confirmation by test. The controller holds no payment identifier at
  any point.

Deferred to Stage 11C, recorded in the Stage 11B section:

- Product-base selection from the catalogue; the form captures a free-text
  specification instead.
- Platform camera and file-picker capture for reference images; the dialog
  accepts validated URLs only.

Validation:

- 146 new tests: 82 on CustomOrderFormController (money parsing, setters,
  derived balance and clamping, reference image add/remove/dedupe, the full
  validation matrix, buildSpec mapping, submission, quoted vs. collected
  deposit, repository failure, duplicate ID, reset), 26 on the form page, 24
  on the confirmation page including the no-payment-identifier assertion, 3 on
  the OrdersPage entry point, 6 on MobileCustomOrderPage, and 5 on the web
  route.
- 2,044/2,044 passing across all 11 packages and apps, up from the 1,898
  baseline at 0e3987b, with no regression.
- dart analyze --fatal-infos clean across all packages and apps.
- dart format --line-length 100 clean on every file touched by this slice.

Test-harness note: the intake form is taller than the default 800x600 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 to 0.12.20, meta 1.18.0 to 1.19.0, test_api
0.7.11 to 0.7.12, vector_math 2.2.0 to 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.

Closes: P2.2 (intake UI portion)
This commit is contained in:
Mike Kell 2026-09-25 15:45:04 -04:00
parent 0e3987bc1f
commit 595d0b5a93
24 changed files with 3064 additions and 49 deletions

View File

@ -242,7 +242,7 @@ kell_creations/
| `CustomOrderSpec` value object | ❌ Missing (Stage 11A) |
| `DepositInfo` value object | ❌ Missing (Stage 11A) |
| Custom order fulfillment status pipeline | ❌ Missing (Stage 11C) |
| `CustomOrderFormPage` | ❌ Missing (Stage 11B) |
| `CustomOrderFormPage` | ✅ Delivered (11B) |
| `CustomOrderTrackingPage` | ❌ Missing (Stage 11C) |
| `OrderChannel` enum (woocommerce, square, manual) | ❌ 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
> 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).
**Dependencies:** Stage 9A ✅, Stage 11A ✅
**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.
**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)
@ -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 inventory sync** | ✅ Repository wired via `square()` / `multi()` factories |
| **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) |
| **Supplies tracking** | ❌ 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`
- 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
- next branch: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI
- total tests on branch: 1898/1898 passing (+423 new, all in feature_orders); dart analyze --fatal-infos clean across all 11 packages and apps
- in progress: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI; complete, pending merge
- 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
- 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`
## 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
- status: merged to main

View File

@ -143,24 +143,25 @@ Rules:
### 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
- All packages passing
- latest reported count for `core`: `90/90 passed`
- latest reported count for `design_system`: `51/51 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 `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 `data`: `63/63 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)
- 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`)
- 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.
@ -350,15 +351,70 @@ Validation:
- 1,898 total across all packages; no regression from the 1,475 baseline
- `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
**`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
capture, pricing and deposit entry, estimated completion picker
- `CustomOrderConfirmationPage` — summary with a deposit receipt
- Wire into mobile shell navigation and web routing
- Tests for form validation and the creation flow
- `CustomOrderTrackingPage` — custom orders filtered by fulfillment stage
- Status progression UI driving `advanceFulfillmentStatus()`, with illegal
transitions surfaced as the domain's own rejection reasons
- Order history / timeline view of status changes with timestamps
- 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 |
| ----- | ---------------------------------------------------------------------------------- | --------------------------- |
| 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.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C |
| 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`
> 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
@ -1079,16 +1138,21 @@ Provide mobile and web interfaces for creating custom orders, especially at mark
##### Requirements
- Create `CustomOrderFormPage` (shared or platform-specific presentation)
- Customer information capture (name, email, phone)
- Product base selection (from catalog) or free-text description
- Customization fields: material, color, size/dimensions, special instructions
- Reference image capture (camera on mobile, file picker on web)
- Pricing: estimated price, deposit amount, deposit payment method
- Estimated completion date picker
- Create `CustomOrderConfirmationPage` — summary with digital receipt for deposit
- Wire into mobile shell navigation and web routing
- Tests for form validation, order creation flow
- ✅ Create `CustomOrderFormPage` (shared presentation, hosted by both targets)
- ✅ Customer information capture (name, email, phone)
- ⚠️ Free-text description delivered; product base selection from the
catalogue deferred to Stage 11C
- ✅ Customization fields: material, color, size/dimensions, special instructions
- ⚠️ Reference images captured as URLs to already-uploaded media; the
platform camera / file picker lands with the media pipeline
- ✅ Pricing: estimated price, deposit amount, deposit payment method, live
balance due — all money as integer cents
- ✅ 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

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:flutter/material.dart';
import 'mobile_custom_order_page.dart';
import 'mobile_order_detail_page.dart';
/// 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 Column(
return Stack(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildOrderCount(),
Expanded(child: _buildOrderList()),
Column(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildOrderCount(),
Expanded(child: _buildOrderList()),
],
),
Positioned(
right: KcSpacing.md,
bottom: KcSpacing.md,
child: FloatingActionButton.extended(
heroTag: 'new-custom-order',
onPressed: _navigateToCustomOrderForm,
icon: const Icon(Icons.design_services_outlined),
label: const Text('Custom Order'),
),
),
],
);
},
);
}
/// Pushes the custom order intake form and refreshes the list when a
/// commission was created.
Future<void> _navigateToCustomOrderForm() async {
setState(() => _detailPageActive = true);
await Navigator.of(context).push<Order>(
MaterialPageRoute<Order>(
builder: (_) => MobileCustomOrderPage(repository: widget.repository),
),
);
if (!mounted) return;
setState(() => _detailPageActive = false);
await _controller.load();
}
/// Search bar with real-time filtering.
Widget _buildSearchBar() {
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 ─────────────────────────────────────────────────
/// 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 products = '/products';
static const String orders = '/orders';
static const String customOrder = '/orders/custom';
static const String finance = '/finance';
static const String policy = '/policy';
static const String integrations = '/integrations';
@ -83,12 +84,25 @@ abstract final class AppRoutes {
repository: AppScope.of(context).ordersRepository,
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context),
initialFilter: args['filter'],
initialQuery: args['query'],
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:
return _buildRoute(
settings,

View File

@ -176,10 +176,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@ -192,10 +192,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
path:
dependency: transitive
description:
@ -253,10 +253,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@ -269,10 +269,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.3"
vm_service:
dependency: transitive
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`
(REST API v3, custom order state round-tripped through `_kc_`-prefixed order
meta-data).
- **Application layer** — `OrdersController`, `CheckoutController`, and the
`GetOrders` / `UpdateOrderStatus` / `AddOrderNote` / `CreateOrder` use cases.
- **Application layer** — `OrdersController`, `CheckoutController`,
`CustomOrderFormController`, and the `GetOrders` / `UpdateOrderStatus` /
`AddOrderNote` / `CreateOrder` / `CreateCustomOrder` / `RecordDeposit` use
cases.
- **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`,
`OrderStatusChip`.
`OrderStatusChip`, `CustomOrderFormPage`, `CustomOrderConfirmationPage`,
`FulfillmentStatusChip`, `showReferenceImageDialog`.
## Getting started
@ -80,6 +83,25 @@ await repository.recordDeposit(
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
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/checkout_controller.dart';
export 'src/application/create_custom_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/orders_controller.dart';
export 'src/application/payment_service.dart';
export 'src/application/record_deposit.dart';
export 'src/application/update_order_status.dart';
export 'src/data/fake_orders_repository.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/payment_method.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_page.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_detail_panel.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.
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'`).
final String? initialFilter;
@ -40,6 +46,7 @@ class OrdersPage extends StatefulWidget {
required this.repository,
this.onViewProduct,
this.onViewInventory,
this.onCreateCustomOrder,
this.initialFilter,
this.initialQuery,
this.initialSelectedId,
@ -137,6 +144,7 @@ class _OrdersPageState extends State<OrdersPage> {
orderCount: _controller.orders.length,
onSearchChanged: _controller.setSearchQuery,
onFilterChanged: _controller.setFilter,
onCreateCustomOrder: widget.onCreateCustomOrder,
),
Expanded(
child: isWide
@ -172,6 +180,7 @@ class _OrdersToolbar extends StatelessWidget {
final int orderCount;
final ValueChanged<String> onSearchChanged;
final ValueChanged<String?> onFilterChanged;
final VoidCallback? onCreateCustomOrder;
const _OrdersToolbar({
required this.searchController,
@ -179,6 +188,7 @@ class _OrdersToolbar extends StatelessWidget {
required this.orderCount,
required this.onSearchChanged,
required this.onFilterChanged,
this.onCreateCustomOrder,
});
static const _filters = [
@ -197,6 +207,19 @@ class _OrdersToolbar extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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
TextField(
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(
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);
});
});
// ── 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);
});
});
}