diff --git a/PROJECT_STATE_AUDIT.md b/PROJECT_STATE_AUDIT.md index a90af5a..daf65d6 100644 --- a/PROJECT_STATE_AUDIT.md +++ b/PROJECT_STATE_AUDIT.md @@ -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 | diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 5e74962..ac8cb43 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -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 diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index aae943a..1c5aeb9 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -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 diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_page.dart new file mode 100644 index 0000000..297ed8e --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_page.dart @@ -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); + } + }, + ), + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart index 2538a8f..46fd4b4 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart @@ -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 { 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 _navigateToCustomOrderForm() async { + setState(() => _detailPageActive = true); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MobileCustomOrderPage(repository: widget.repository), + ), + ); + if (!mounted) return; + setState(() => _detailPageActive = false); + await _controller.load(); + } + /// Search bar with real-time filtering. Widget _buildSearchBar() { return Padding( diff --git a/kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_page_test.dart b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_page_test.dart new file mode 100644 index 0000000..c0a00af --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_page_test.dart @@ -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); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart index c4f331b..053e440 100644 --- a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart +++ b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart @@ -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". diff --git a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart index 50cfb96..c9656bb 100644 --- a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -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, diff --git a/kell_creations_apps/apps/kell_web/pubspec.lock b/kell_creations_apps/apps/kell_web/pubspec.lock index cfa90db..e2866a8 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.lock +++ b/kell_creations_apps/apps/kell_web/pubspec.lock @@ -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: diff --git a/kell_creations_apps/apps/kell_web/test/routing/custom_order_route_test.dart b/kell_creations_apps/apps/kell_web/test/routing/custom_order_route_test.dart new file mode 100644 index 0000000..326af2f --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/routing/custom_order_route_test.dart @@ -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 _go(WidgetTester tester, String route) async { + final navigator = tester.state(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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/README.md b/kell_creations_apps/packages/feature_orders/README.md index 6aa1375..96f5dae 100644 --- a/kell_creations_apps/packages/feature_orders/README.md +++ b/kell_creations_apps/packages/feature_orders/README.md @@ -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 diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart index 13b5bc9..6a3e87b 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -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'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/create_custom_order.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/create_custom_order.dart new file mode 100644 index 0000000..d5f4c0b --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/create_custom_order.dart @@ -0,0 +1,22 @@ +import '../domain/custom_order_spec.dart'; +import '../domain/deposit_info.dart'; +import '../domain/order.dart'; +import '../domain/order_action_result.dart'; +import '../domain/orders_repository.dart'; + +/// Use case: create a made-to-order commission and persist it. +/// +/// The repository stamps [Order.orderType], the supplied [CustomOrderSpec], +/// the optional [DepositInfo], and the initial fulfillment stage, so callers +/// never have to assemble a consistent custom-order aggregate by hand. +class CreateCustomOrder { + final OrdersRepository repository; + + CreateCustomOrder(this.repository); + + Future call( + Order order, { + required CustomOrderSpec spec, + DepositInfo? deposit, + }) => repository.createCustomOrder(order, spec: spec, deposit: deposit); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_form_controller.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_form_controller.dart new file mode 100644 index 0000000..ad926a0 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_form_controller.dart @@ -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 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 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 = {}; + + 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 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.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 = { + '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()}'; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/record_deposit.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/record_deposit.dart new file mode 100644 index 0000000..fdf8d68 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/record_deposit.dart @@ -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 call( + String id, { + required int amountCents, + required DateTime paidAt, + int? totalAmountCents, + }) => repository.recordDeposit( + id, + amountCents: amountCents, + paidAt: paidAt, + totalAmountCents: totalAmountCents, + ); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_confirmation_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_confirmation_page.dart new file mode 100644 index 0000000..43d7b73 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_confirmation_page.dart @@ -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)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_form_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_form_page.dart new file mode 100644 index 0000000..9dc923c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_form_page.dart @@ -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 createState() => _CustomOrderFormPageState(); +} + +class _CustomOrderFormPageState extends State { + 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 _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 _addReferenceImage() async { + final result = await showReferenceImageDialog(context); + if (result == null) return; + _controller.addReferenceImageUrl(result.url); + } + + Future _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( + 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( + 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 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 urls; + final bool enabled; + final VoidCallback onAdd; + final ValueChanged 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'), + ), + ), + ], + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart index 59fe9cf..1c84f80 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart @@ -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 { 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 onSearchChanged; final ValueChanged 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, diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_status_chip.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_status_chip.dart new file mode 100644 index 0000000..062fef1 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_status_chip.dart @@ -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); + } + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/reference_image_dialog.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/reference_image_dialog.dart new file mode 100644 index 0000000..ab3c734 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/reference_image_dialog.dart @@ -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 showReferenceImageDialog(BuildContext context) { + return showDialog( + 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')), + ], + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/test/custom_order_form_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/custom_order_form_controller_test.dart new file mode 100644 index 0000000..0e27bdf --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/custom_order_form_controller_test.dart @@ -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 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 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_confirmation_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_confirmation_page_test.dart new file mode 100644 index 0000000..d920361 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_confirmation_page_test.dart @@ -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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_form_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_form_page_test.dart new file mode 100644 index 0000000..c0c1c5a --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_form_page_test.dart @@ -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 _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 _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), findsNothing); + + controller.setDepositCollected(true); + await tester.pump(); + + expect(find.byType(SegmentedButton), 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart index dbf86d3..6279efe 100644 --- a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart @@ -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); + }); + }); }