From 47b13b1ed1a9c02a8b58c5e10b2e8010642dc353 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 25 Jul 2026 17:37:36 -0400 Subject: [PATCH] feat(stage-10c): Square Mobile POS / Checkout - Add PaymentMethod enum (cash, card) with label/iconHint extensions - Add PaymentResult value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted) - Add PaymentService abstract contract + PaymentException - Add CheckoutController (ChangeNotifier) with CartEntry and sealed CheckoutState hierarchy - Add FakePaymentService (always succeeds, counter-based IDs) - Update feature_orders barrel exports - Add MobileCheckoutPage (product selection -> cart -> payment -> receipt flow) - Add Sales tab (index 4) to MobileShell; 6 tabs total - Wire paymentService + checkoutCatalogue into all MobileAppServices factories - Add 36 new feature_orders tests (234 total) - Add 18 new kell_mobile tests (72 total); 1373 total across all packages - Update widget_test.dart: destination count 5 -> 6 - Update build_execution_tracker.md and master_development_brief.md All tests passing. dart analyze clean. --- docs/development/build_execution_tracker.md | 38 +- docs/development/master_development_brief.md | 43 +- .../lib/composition/mobile_app_services.dart | 25 + .../lib/pages/mobile_checkout_page.dart | 471 ++++++++++++++++++ .../kell_mobile/lib/shell/mobile_shell.dart | 17 +- .../composition/mobile_app_services_test.dart | 70 +++ .../test/pages/mobile_checkout_page_test.dart | 156 ++++++ .../apps/kell_mobile/test/widget_test.dart | 5 +- .../feature_orders/lib/feature_orders.dart | 5 + .../src/application/checkout_controller.dart | 138 +++++ .../lib/src/application/payment_service.dart | 30 ++ .../lib/src/data/fake_payment_service.dart | 32 ++ .../lib/src/domain/payment_method.dart | 30 ++ .../lib/src/domain/payment_result.dart | 59 +++ .../test/checkout_controller_test.dart | 159 ++++++ .../test/fake_payment_service_test.dart | 41 ++ .../test/payment_method_test.dart | 26 + .../test/payment_result_test.dart | 77 +++ 18 files changed, 1395 insertions(+), 27 deletions(-) create mode 100644 kell_creations_apps/apps/kell_mobile/lib/pages/mobile_checkout_page.dart create mode 100644 kell_creations_apps/apps/kell_mobile/test/pages/mobile_checkout_page_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/application/checkout_controller.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/application/payment_service.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/data/fake_payment_service.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/payment_method.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/payment_result.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/checkout_controller_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/fake_payment_service_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/payment_method_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/payment_result_test.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index b2fdb96..1ba2b7b 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -6,12 +6,42 @@ - audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker - merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean - main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25) -- active branch: none — on `main` -- next branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout; or `feat/custom-order-domain` (Stage 11A) — Custom order domain and data -- total tests on main: 651/651 passing (545 feature_wordpress + 52 kell_web + 54 kell_mobile); dart analyze clean across all packages +- active branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout +- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data +- total tests on branch: 1373/1373 passing (+45 new: 36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); dart analyze clean across all packages ## Slice tracker +### feat/square-mobile-pos + +- status: in progress (ready to merge) +- branch: `feat/square-mobile-pos` +- stage: 10C — Square Mobile POS / Checkout +- date: 2026-07-25 +- inspection: complete +- implementation: complete +- files changed: + - `feature_orders/lib/src/domain/payment_method.dart` — `PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions + - `feature_orders/lib/src/domain/payment_result.dart` — `PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted) + - `feature_orders/lib/src/application/payment_service.dart` — `PaymentService` abstract contract + `PaymentException` + - `feature_orders/lib/src/application/checkout_controller.dart` — `CheckoutController` (ChangeNotifier), `CartEntry`, sealed `CheckoutState` hierarchy (idle/processing/success/error) + - `feature_orders/lib/src/data/fake_payment_service.dart` — `FakePaymentService` (always succeeds, counter-based IDs) + - `feature_orders/lib/feature_orders.dart` — barrel updated with all new exports + - `kell_mobile/lib/pages/mobile_checkout_page.dart` — `MobileCheckoutPage` (product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback) + - `kell_mobile/lib/shell/mobile_shell.dart` — Sales tab (index 4) added; `CheckoutController` initialised in `didChangeDependencies`; `_titles` updated to 6 entries + - `kell_mobile/lib/composition/mobile_app_services.dart` — `paymentService` field + `checkoutCatalogue` field; `_fakeCatalogue` const; all factory constructors updated + - `kell_mobile/test/widget_test.dart` — updated destination count assertion 5 → 6 +- tests added: + - `feature_orders/test/payment_method_test.dart` — 5 tests + - `feature_orders/test/payment_result_test.dart` — 11 tests + - `feature_orders/test/fake_payment_service_test.dart` — 4 tests + - `feature_orders/test/checkout_controller_test.dart` — 16 tests + - `kell_mobile/test/composition/mobile_app_services_test.dart` — 11 new tests (paymentService + checkoutCatalogue groups) + - `kell_mobile/test/pages/mobile_checkout_page_test.dart` — 11 widget tests +- tests: passed (1373/1373 total; feature_orders 234/234 +36; kell_mobile 72/72 +18) +- analyze: passed (dart analyze — no issues found across all packages) +- brief updated: yes + ### feat/description-only-edit - status: merged to main @@ -693,4 +723,4 @@ - `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests - tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages) - analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages) -- brief updated: yes +- brief updated: yes \ No newline at end of file diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 031267a..ced0888 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -148,15 +148,15 @@ Rules: - latest reported count for `core`: `24/24 passed` - latest reported count for `design_system`: `41/41 passed` - latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C) -- latest reported count for `feature_orders`: `198/198 passed` -- latest reported count for `feature_wordpress`: `506/506 passed` ← +31 new (Stage 10D, pending merge) -- latest reported count for `kell_web`: `24/24 passed` +- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS) +- latest reported count for `feature_wordpress`: `545/545 passed` +- latest reported count for `kell_web`: `52/52 passed` - 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`: `26/26 passed` -- total: `1,015/1,015 passed` (Stage 10C `main` baseline: 957 + 58 Stage 10B + 86 Stage 10C new tests; Stage 10D adds 31 more on pending branch) -- baseline commit: merge of `feat/square-catalog-sync-repositories` into `main` (Stage 10C complete, 2026-07-25) +- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS) +- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`) +- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge > **Note:** Stage 10D (`feat/square-catalog-sync-service`) is complete and ready for merge. The `feature_wordpress` count of 506/506 reflects the post-merge state. The `main` baseline currently shows 475/475 for `feature_wordpress` until Stage 10D is merged. @@ -216,22 +216,25 @@ Delivered: - 8 new panel tests + 4 integrations page tests per app + 5 composition tests per app (651 total across tested packages) - Analyze clean +### Stage 10C — complete (2026-07-25) + +**`feat/square-mobile-pos`** — Square Mobile POS / Checkout + +Delivered: + +- `PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions in `feature_orders` +- `PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted) in `feature_orders` +- `PaymentService` abstract contract + `PaymentException` in `feature_orders` +- `CheckoutController` (`ChangeNotifier`) with sealed `CheckoutState` hierarchy (idle/processing/success/error) and `CartEntry` in `feature_orders` +- `FakePaymentService` (always succeeds, counter-based IDs) in `feature_orders` +- `MobileCheckoutPage` — product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback in `kell_mobile` +- Sales tab (index 4) added to `MobileShell` bottom navigation (6 tabs total) +- `paymentService` + `checkoutCatalogue` fields wired into all `MobileAppServices` factory constructors +- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages +- Analyze clean + ### Next recommended branch -**`feat/square-mobile-pos`** — Stage 10C: Square Mobile POS / Checkout (P2.1) - -Branch from latest `main` **after** merging all pending branches (10D, 10E, 10F). This slice adds in-person payment processing capability to the Android app: - -- Create `PaymentService` abstraction in application layer -- Create `PaymentResult` domain model (amount, method, transactionId, receiptUrl) -- Create `MobileCheckoutPage` — product selection → cart → payment flow -- Support cash recording (manual entry, no Square hardware needed) -- Support card payment via Square Reader SDK -- After payment: create order record, deduct inventory -- Add `Sales` tab to mobile navigation - -**Alternatively**, if POS is deferred, the next highest-value branch is: - **`feat/custom-order-domain`** — Stage 11A: Custom order domain and data - Add `OrderType` enum, `CustomOrderSpec`, `DepositInfo` value objects diff --git a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart index 5cccb4d..32c506a 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart @@ -5,6 +5,16 @@ import 'package:feature_policy/feature_policy.dart'; import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:integrations/integrations.dart'; +/// Default catalogue items used in fake/wordpress mode where no Square catalog +/// is available. These seed the [MobileCheckoutPage] product list. +const _fakeCatalogue = [ + OrderItem(productName: 'Macramé Wall Hanging', sku: 'MWH-001', quantity: 1, unitPrice: 45.00), + OrderItem(productName: 'Beaded Bracelet', sku: 'BB-002', quantity: 1, unitPrice: 12.00), + OrderItem(productName: 'Ceramic Mug', sku: 'CM-003', quantity: 1, unitPrice: 18.00), + OrderItem(productName: 'Soy Candle — Lavender', sku: 'SC-004', quantity: 1, unitPrice: 14.00), + OrderItem(productName: 'Knitted Scarf', sku: 'KS-005', quantity: 1, unitPrice: 32.00), +]; + /// Holds the concrete service implementations used by `kell_mobile`. /// /// Extends [KcAppServices] from the shared `core` package so that the @@ -24,12 +34,23 @@ class MobileAppServices extends KcAppServices { /// Present only in `square` and `multi` environments. final SquareSyncController? squareSyncController; + /// Payment service used by the mobile POS checkout flow. + final PaymentService paymentService; + + /// Catalogue items available for sale in the [MobileCheckoutPage]. + /// + /// In fake/wordpress mode this is seeded with [_fakeCatalogue]. + /// In square/multi mode this could be populated from the Square catalog. + final List checkoutCatalogue; + const MobileAppServices({ required this.inventoryRepository, required this.ordersRepository, required this.policyRepository, required this.productPublishingRepository, + required this.paymentService, this.squareSyncController, + this.checkoutCatalogue = _fakeCatalogue, }); /// Creates a [MobileAppServices] backed by fake, in-memory repositories. @@ -39,6 +60,7 @@ class MobileAppServices extends KcAppServices { ordersRepository: FakeOrdersRepository(), policyRepository: FakePolicyRepository(), productPublishingRepository: FakeProductPublishingRepository(), + paymentService: FakePaymentService(), ); } @@ -61,6 +83,7 @@ class MobileAppServices extends KcAppServices { ordersRepository: FakeOrdersRepository(), policyRepository: FakePolicyRepository(), productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), + paymentService: FakePaymentService(), ); } @@ -92,6 +115,7 @@ class MobileAppServices extends KcAppServices { policyRepository: FakePolicyRepository(), productPublishingRepository: catalogRepo, squareSyncController: SquareSyncController(syncService: syncService), + paymentService: FakePaymentService(), ); } @@ -135,6 +159,7 @@ class MobileAppServices extends KcAppServices { policyRepository: FakePolicyRepository(), productPublishingRepository: wcPublishingRepo, squareSyncController: SquareSyncController(syncService: syncService), + paymentService: FakePaymentService(), ); } diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_checkout_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_checkout_page.dart new file mode 100644 index 0000000..662a970 --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_checkout_page.dart @@ -0,0 +1,471 @@ +import 'package:design_system/design_system.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// The mobile POS checkout page. +/// +/// Provides a three-step flow: +/// 1. **Product selection** — browse a list of [OrderItem] catalogue entries +/// and add them to the cart. +/// 2. **Cart review** — see line items, quantities, and the running total. +/// 3. **Payment** — choose Cash or Card and confirm the charge. +/// +/// After a successful payment the page shows a receipt summary and offers a +/// "New Sale" button to reset the controller and start again. +/// +/// The [CheckoutController] is created externally and injected so that the +/// parent shell can hold a reference to it (e.g. to badge the Sales tab with +/// the cart item count in a future iteration). +class MobileCheckoutPage extends StatefulWidget { + final CheckoutController controller; + + /// Catalogue items available for sale. In fake mode these are seeded by + /// [MobileAppServices]; in a real deployment they come from the product + /// publishing repository. + final List catalogue; + + const MobileCheckoutPage({super.key, required this.controller, required this.catalogue}); + + @override + State createState() => _MobileCheckoutPageState(); +} + +class _MobileCheckoutPageState extends State { + // ── lifecycle ────────────────────────────────────────────────────────────── + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onStateChange); + } + + @override + void dispose() { + widget.controller.removeListener(_onStateChange); + super.dispose(); + } + + void _onStateChange() => setState(() {}); + + // ── helpers ──────────────────────────────────────────────────────────────── + + CheckoutController get _ctrl => widget.controller; + + String _formatCents(int cents) { + final d = cents ~/ 100; + final c = cents % 100; + return '\$$d.${c.toString().padLeft(2, '0')}'; + } + + // ── build ────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final state = _ctrl.state; + + if (state is CheckoutSuccess) { + return _ReceiptView(result: state.result, onNewSale: _ctrl.reset); + } + + return Column( + children: [ + // ── cart summary bar ──────────────────────────────────────────────── + _CartSummaryBar( + itemCount: _ctrl.cart.fold(0, (s, e) => s + e.quantity), + totalCents: _ctrl.totalCents, + formatCents: _formatCents, + onClear: _ctrl.cart.isEmpty ? null : _ctrl.clearCart, + ), + const Divider(height: 1), + // ── body ──────────────────────────────────────────────────────────── + Expanded( + child: state is CheckoutProcessing + ? const Center(child: CircularProgressIndicator()) + : _buildBody(context, state), + ), + // ── error banner ──────────────────────────────────────────────────── + if (state is CheckoutError) _ErrorBanner(message: state.message), + // ── pay button ────────────────────────────────────────────────────── + if (_ctrl.cart.isNotEmpty && state is! CheckoutProcessing) + _PaymentBar( + totalCents: _ctrl.totalCents, + formatCents: _formatCents, + onPay: (method) => _ctrl.processPayment(method), + ), + ], + ); + } + + Widget _buildBody(BuildContext context, CheckoutState state) { + return DefaultTabController( + length: 2, + child: Column( + children: [ + const TabBar( + tabs: [ + Tab(icon: Icon(Icons.storefront_outlined), text: 'Products'), + Tab(icon: Icon(Icons.shopping_cart_outlined), text: 'Cart'), + ], + ), + Expanded( + child: TabBarView( + children: [ + _ProductList(catalogue: widget.catalogue, onAdd: _ctrl.addItem), + _CartList(cart: _ctrl.cart, formatCents: _formatCents, onRemove: _ctrl.removeItem), + ], + ), + ), + ], + ), + ); + } +} + +// ── Cart summary bar ────────────────────────────────────────────────────────── + +class _CartSummaryBar extends StatelessWidget { + final int itemCount; + final int totalCents; + final String Function(int) formatCents; + final VoidCallback? onClear; + + const _CartSummaryBar({ + required this.itemCount, + required this.totalCents, + required this.formatCents, + this.onClear, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.shopping_cart, color: KcColors.denimBlue, size: 20), + const SizedBox(width: 8), + Text( + '$itemCount item${itemCount == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const Spacer(), + Text( + formatCents(totalCents), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: KcColors.denimBlue, + ), + ), + if (onClear != null) ...[ + const SizedBox(width: 8), + TextButton(onPressed: onClear, child: const Text('Clear')), + ], + ], + ), + ); + } +} + +// ── Product list ────────────────────────────────────────────────────────────── + +class _ProductList extends StatelessWidget { + final List catalogue; + final void Function(OrderItem) onAdd; + + const _ProductList({required this.catalogue, required this.onAdd}); + + @override + Widget build(BuildContext context) { + if (catalogue.isEmpty) { + return const KcEmptyState( + icon: Icons.storefront_outlined, + message: 'No products available. Add products to the catalogue to start selling.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: catalogue.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, i) { + final item = catalogue[i]; + return KcCard( + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + title: Text(item.productName, style: Theme.of(context).textTheme.bodyMedium), + subtitle: Text('SKU: ${item.sku}', style: Theme.of(context).textTheme.bodySmall), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '\$${item.unitPrice.toStringAsFixed(2)}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + FilledButton.tonal( + onPressed: () { + HapticFeedback.lightImpact(); + onAdd(item); + }, + style: FilledButton.styleFrom( + minimumSize: const Size(40, 36), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: const Icon(Icons.add, size: 18), + ), + ], + ), + ), + ); + }, + ); + } +} + +// ── Cart list ───────────────────────────────────────────────────────────────── + +class _CartList extends StatelessWidget { + final List cart; + final String Function(int) formatCents; + final void Function(String sku) onRemove; + + const _CartList({required this.cart, required this.formatCents, required this.onRemove}); + + @override + Widget build(BuildContext context) { + if (cart.isEmpty) { + return const KcEmptyState( + icon: Icons.shopping_cart_outlined, + message: 'Cart is empty. Tap + on a product to add it to the cart.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: cart.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, i) { + final entry = cart[i]; + return KcCard( + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + title: Text(entry.item.productName, style: Theme.of(context).textTheme.bodyMedium), + subtitle: Text( + '${formatCents(entry.lineTotalCents)} · qty ${entry.quantity}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: IconButton( + icon: const Icon(Icons.remove_circle_outline), + tooltip: 'Remove one', + onPressed: () { + HapticFeedback.lightImpact(); + onRemove(entry.item.sku); + }, + ), + ), + ); + }, + ); + } +} + +// ── Payment bar ─────────────────────────────────────────────────────────────── + +class _PaymentBar extends StatelessWidget { + final int totalCents; + final String Function(int) formatCents; + final void Function(PaymentMethod) onPay; + + const _PaymentBar({required this.totalCents, required this.formatCents, required this.onPay}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + Text( + 'Charge ${formatCents(totalCents)}', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.payments_outlined), + label: const Text('Cash'), + onPressed: () => _confirmPayment(context, PaymentMethod.cash), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.credit_card), + label: const Text('Card'), + onPressed: () => _confirmPayment(context, PaymentMethod.card), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _confirmPayment(BuildContext context, PaymentMethod method) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Confirm ${method.label} Payment'), + content: Text('Charge ${formatCents(totalCents)} via ${method.label}?'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Confirm')), + ], + ), + ); + if (confirmed == true) { + HapticFeedback.mediumImpact(); + onPay(method); + } + } +} + +// ── Receipt view ────────────────────────────────────────────────────────────── + +class _ReceiptView extends StatelessWidget { + final PaymentResult result; + final VoidCallback onNewSale; + + const _ReceiptView({required this.result, required this.onNewSale}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Colors.green.shade600, size: 64), + const SizedBox(height: 16), + Text( + 'Payment Complete', + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + result.amountFormatted, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'via ${result.method.label}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600), + ), + const SizedBox(height: 16), + KcCard( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ReceiptRow(label: 'Transaction ID', value: result.transactionId), + _ReceiptRow(label: 'Completed', value: _formatTime(result.completedAt)), + if (result.receiptUrl != null) + _ReceiptRow(label: 'Receipt', value: result.receiptUrl!), + ], + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + icon: const Icon(Icons.add_shopping_cart), + label: const Text('New Sale'), + onPressed: () { + HapticFeedback.lightImpact(); + onNewSale(); + }, + ), + ], + ), + ), + ); + } + + String _formatTime(DateTime dt) { + final local = dt.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')} ' + '${local.month}/${local.day}/${local.year}'; + } +} + +class _ReceiptRow extends StatelessWidget { + final String label; + final String value; + + const _ReceiptRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600), + ), + ), + Expanded(child: Text(value, style: Theme.of(context).textTheme.bodySmall)), + ], + ), + ); + } +} + +// ── Error banner ────────────────────────────────────────────────────────────── + +class _ErrorBanner extends StatelessWidget { + final String message; + + const _ErrorBanner({required this.message}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: Colors.red.shade50, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade700, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800), + ), + ), + ], + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index 10bacea..c61b62f 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -1,4 +1,5 @@ import 'package:core/core.dart'; +import 'package:feature_orders/feature_orders.dart'; import 'package:feature_policy/feature_policy.dart'; import 'package:flutter/material.dart'; @@ -7,6 +8,7 @@ import '../dashboard/application/dashboard_controller.dart'; import '../dashboard/application/get_dashboard_summary.dart'; import '../pages/dashboard_page.dart'; import '../pages/finance_placeholder_page.dart'; +import '../pages/mobile_checkout_page.dart'; import '../pages/mobile_integrations_page.dart'; import '../pages/mobile_inventory_page.dart'; import '../pages/mobile_orders_page.dart'; @@ -33,8 +35,9 @@ class _MobileShellState extends State { /// Dashboard controller is created once and reused across rebuilds to avoid /// re-triggering data loads on every [setState] call (e.g. tab switches). late DashboardController _dashboardController; + late CheckoutController _checkoutController; - static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More']; + static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'Sales', 'More']; @override void didChangeDependencies() { @@ -50,6 +53,7 @@ class _MobileShellState extends State { ordersRepository: services.ordersRepository, ), ); + _checkoutController = CheckoutController(paymentService: services.paymentService); _dashboardControllerInitialised = true; } } @@ -59,6 +63,7 @@ class _MobileShellState extends State { @override void dispose() { _dashboardController.dispose(); + _checkoutController.dispose(); super.dispose(); } @@ -101,6 +106,11 @@ class _MobileShellState extends State { selectedIcon: Icon(Icons.receipt_long), label: 'Orders', ), + NavigationDestination( + icon: Icon(Icons.point_of_sale_outlined), + selectedIcon: Icon(Icons.point_of_sale), + label: 'Sales', + ), NavigationDestination( icon: Icon(Icons.more_horiz), selectedIcon: Icon(Icons.more_horiz), @@ -138,6 +148,11 @@ class _MobileShellState extends State { }, ); case 4: + return MobileCheckoutPage( + controller: _checkoutController, + catalogue: services.checkoutCatalogue, + ); + case 5: return const _MorePage(); default: return const Center(child: Text('Unknown section')); diff --git a/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart b/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart index 145351d..7204366 100644 --- a/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart +++ b/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart @@ -1,5 +1,7 @@ import 'package:core/core.dart'; import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_policy/feature_policy.dart'; import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kell_mobile/composition/mobile_app_services.dart'; @@ -242,5 +244,73 @@ void main() { expect(result.services.inventoryRepository, isA()); }); }); + + // ── paymentService composition (Stage 10C) ──────────────────────────────── + + group('paymentService', () { + test('fake() provides a FakePaymentService', () { + final services = MobileAppServices.fake(); + expect(services.paymentService, isA()); + }); + + test('wordpress() provides a FakePaymentService', () { + final services = MobileAppServices.wordpress( + siteUrl: 'https://store.kellcreations.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + ); + expect(services.paymentService, isA()); + }); + + test('square() provides a FakePaymentService', () { + final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123'); + expect(services.paymentService, isA()); + }); + + test('multi() provides a FakePaymentService', () { + final services = MobileAppServices.multi( + wcSiteUrl: 'https://store.kellcreations.com', + wcConsumerKey: 'ck_test', + wcConsumerSecret: 'cs_test', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + expect(services.paymentService, isA()); + }); + }); + + // ── checkoutCatalogue composition (Stage 10C) ───────────────────────────── + + group('checkoutCatalogue', () { + test('fake() provides a non-empty default catalogue', () { + final services = MobileAppServices.fake(); + expect(services.checkoutCatalogue, isNotEmpty); + }); + + test('default catalogue items have valid SKUs and prices', () { + final services = MobileAppServices.fake(); + for (final item in services.checkoutCatalogue) { + expect(item.sku, isNotEmpty); + expect(item.unitPrice, greaterThan(0)); + expect(item.productName, isNotEmpty); + } + }); + + test('custom catalogue can be injected', () { + const custom = [ + OrderItem(productName: 'Custom Item', sku: 'CI-001', quantity: 1, unitPrice: 99.99), + ]; + final services = MobileAppServices( + inventoryRepository: FakeInventoryRepository(), + ordersRepository: FakeOrdersRepository(), + policyRepository: FakePolicyRepository(), + productPublishingRepository: FakeProductPublishingRepository(), + paymentService: FakePaymentService(), + checkoutCatalogue: custom, + ); + expect(services.checkoutCatalogue.length, 1); + expect(services.checkoutCatalogue.first.sku, 'CI-001'); + }); + }); }); } diff --git a/kell_creations_apps/apps/kell_mobile/test/pages/mobile_checkout_page_test.dart b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_checkout_page_test.dart new file mode 100644 index 0000000..d5617d4 --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_checkout_page_test.dart @@ -0,0 +1,156 @@ +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_checkout_page.dart'; + +const _catalogue = [ + OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00), + OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50), +]; + +Widget _buildPage({CheckoutController? controller, List catalogue = _catalogue}) { + final ctrl = controller ?? CheckoutController(paymentService: FakePaymentService()); + return MaterialApp( + home: Scaffold( + body: MobileCheckoutPage(controller: ctrl, catalogue: catalogue), + ), + ); +} + +void main() { + group('MobileCheckoutPage', () { + late CheckoutController controller; + + setUp(() { + controller = CheckoutController(paymentService: FakePaymentService()); + }); + + tearDown(() => controller.dispose()); + + // ── initial render ──────────────────────────────────────────────────────── + + testWidgets('renders Products and Cart tabs', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + expect(find.text('Products'), findsOneWidget); + expect(find.text('Cart'), findsOneWidget); + }); + + testWidgets('shows cart summary bar with 0 items initially', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + expect(find.text('0 items'), findsOneWidget); + }); + + testWidgets('shows product names from catalogue', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + expect(find.text('Widget A'), findsOneWidget); + expect(find.text('Widget B'), findsOneWidget); + }); + + testWidgets('shows empty state when catalogue is empty', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller, catalogue: const [])); + expect( + find.text('No products available. Add products to the catalogue to start selling.'), + findsOneWidget, + ); + }); + + // ── add to cart ─────────────────────────────────────────────────────────── + + testWidgets('tapping + adds item to cart and updates summary', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + + // Tap the + button for Widget A (first add icon) + final addButtons = find.byIcon(Icons.add); + expect(addButtons, findsWidgets); + await tester.tap(addButtons.first); + await tester.pump(); + + expect(find.text('1 item'), findsOneWidget); + }); + + testWidgets('adding two different items shows 2 items in summary', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + + final addButtons = find.byIcon(Icons.add); + await tester.tap(addButtons.first); + await tester.pump(); + await tester.tap(addButtons.last); + await tester.pump(); + + expect(find.text('2 items'), findsOneWidget); + }); + + // ── payment bar ─────────────────────────────────────────────────────────── + + testWidgets('payment bar appears after adding item', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + + expect(find.text('Cash'), findsOneWidget); + expect(find.text('Card'), findsOneWidget); + }); + + testWidgets('payment bar is hidden when cart is empty', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + expect(find.text('Cash'), findsNothing); + expect(find.text('Card'), findsNothing); + }); + + // ── cart tab ────────────────────────────────────────────────────────────── + + testWidgets('switching to Cart tab shows empty state when cart is empty', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + + // Tap the Cart tab by its icon to avoid ambiguity with the 'Cart' label + // in the summary bar (which also contains the word 'Cart'). + await tester.tap(find.byIcon(Icons.shopping_cart_outlined)); + await tester.pumpAndSettle(); + + // The KcEmptyState message for an empty cart. + expect(find.text('Cart is empty. Tap + on a product to add it to the cart.'), findsOneWidget); + }); + + // ── receipt view ────────────────────────────────────────────────────────── + + testWidgets('shows receipt view after successful payment', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + + // Add item + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + + // Tap Cash button + await tester.tap(find.text('Cash')); + await tester.pumpAndSettle(); + + // Confirm dialog + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + expect(find.text('Payment Complete'), findsOneWidget); + expect(find.text('New Sale'), findsOneWidget); + }); + + testWidgets('New Sale button resets to idle state', (tester) async { + await tester.pumpWidget(_buildPage(controller: controller)); + + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + await tester.tap(find.text('Cash')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + expect(find.text('Payment Complete'), findsOneWidget); + + await tester.tap(find.text('New Sale')); + await tester.pump(); + + // Back to product list + expect(find.text('Products'), findsOneWidget); + expect(find.text('0 items'), findsOneWidget); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_mobile/test/widget_test.dart b/kell_creations_apps/apps/kell_mobile/test/widget_test.dart index 28882eb..7900706 100644 --- a/kell_creations_apps/apps/kell_mobile/test/widget_test.dart +++ b/kell_creations_apps/apps/kell_mobile/test/widget_test.dart @@ -42,12 +42,13 @@ void main() { expect(find.text('FAKE'), findsOneWidget); }); - testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async { + testWidgets('bottom navigation bar has 6 destinations', (WidgetTester tester) async { await tester.pumpWidget(_buildTestApp()); await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsOneWidget); - expect(find.byType(NavigationDestination), findsNWidgets(5)); + // Dashboard, Inventory, Products, Orders, Sales, More + expect(find.byType(NavigationDestination), findsNWidgets(6)); }); testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async { 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 475dd5a..b6a255f 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -1,11 +1,14 @@ library; export 'src/application/add_order_note.dart'; +export 'src/application/checkout_controller.dart'; export 'src/application/create_order.dart'; export 'src/application/get_orders.dart'; export 'src/application/orders_controller.dart'; +export 'src/application/payment_service.dart'; export 'src/application/update_order_status.dart'; export 'src/data/fake_orders_repository.dart'; +export 'src/data/fake_payment_service.dart'; export 'src/data/woo_commerce_order_mapper.dart'; export 'src/data/woo_commerce_orders_repository.dart'; export 'src/domain/order.dart'; @@ -14,6 +17,8 @@ export 'src/domain/order_item.dart'; export 'src/domain/order_source.dart'; export 'src/domain/order_status.dart'; export 'src/domain/orders_repository.dart'; +export 'src/domain/payment_method.dart'; +export 'src/domain/payment_result.dart'; export 'src/presentation/orders_action_snack_bar.dart'; export 'src/presentation/orders_page.dart'; export 'src/presentation/widgets/add_note_dialog.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/checkout_controller.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/checkout_controller.dart new file mode 100644 index 0000000..248ee10 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/checkout_controller.dart @@ -0,0 +1,138 @@ +import 'package:flutter/foundation.dart'; + +import '../domain/order_item.dart'; +import '../domain/payment_method.dart'; +import '../domain/payment_result.dart'; +import 'payment_service.dart'; + +/// Represents a single item in the active checkout cart. +/// +/// Wraps an [OrderItem] with a mutable quantity so the cart can be updated +/// without mutating the underlying domain model. +class CartEntry { + final OrderItem item; + final int quantity; + + const CartEntry({required this.item, required this.quantity}); + + CartEntry copyWith({int? quantity}) => CartEntry(item: item, quantity: quantity ?? this.quantity); + + /// Total price for this line in cents. + int get lineTotalCents => (item.unitPrice * quantity * 100).round(); +} + +/// Sealed state hierarchy for the checkout flow. +sealed class CheckoutState {} + +/// The cart is being built — no payment in progress. +class CheckoutIdle extends CheckoutState { + final List cart; + CheckoutIdle(this.cart); +} + +/// A payment is being processed. +class CheckoutProcessing extends CheckoutState { + final List cart; + CheckoutProcessing(this.cart); +} + +/// Payment completed successfully. +class CheckoutSuccess extends CheckoutState { + final PaymentResult result; + CheckoutSuccess(this.result); +} + +/// Payment failed. +class CheckoutError extends CheckoutState { + final String message; + final List cart; + CheckoutError(this.message, this.cart); +} + +/// Application-layer controller for the mobile POS checkout flow. +/// +/// Manages the cart (add / remove / clear) and delegates payment processing +/// to the injected [PaymentService]. +/// +/// Extends [ChangeNotifier] so [MobileCheckoutPage] can listen for state +/// changes via [ListenableBuilder] or [AnimatedBuilder]. +class CheckoutController extends ChangeNotifier { + final PaymentService paymentService; + + CheckoutController({required this.paymentService}); + + CheckoutState _state = CheckoutIdle([]); + + CheckoutState get state => _state; + + List get cart { + final s = _state; + if (s is CheckoutIdle) return s.cart; + if (s is CheckoutProcessing) return s.cart; + if (s is CheckoutError) return s.cart; + return []; + } + + /// Total cart value in cents. + int get totalCents => cart.fold(0, (sum, e) => sum + e.lineTotalCents); + + /// Adds [item] to the cart, incrementing quantity if already present. + void addItem(OrderItem item) { + final current = List.from(cart); + final idx = current.indexWhere((e) => e.item.sku == item.sku); + if (idx >= 0) { + current[idx] = current[idx].copyWith(quantity: current[idx].quantity + 1); + } else { + current.add(CartEntry(item: item, quantity: 1)); + } + _state = CheckoutIdle(current); + notifyListeners(); + } + + /// Removes one unit of [sku] from the cart. Removes the entry entirely + /// when quantity reaches zero. + void removeItem(String sku) { + final current = List.from(cart); + final idx = current.indexWhere((e) => e.item.sku == sku); + if (idx < 0) return; + if (current[idx].quantity > 1) { + current[idx] = current[idx].copyWith(quantity: current[idx].quantity - 1); + } else { + current.removeAt(idx); + } + _state = CheckoutIdle(current); + notifyListeners(); + } + + /// Empties the cart and resets to idle. + void clearCart() { + _state = CheckoutIdle([]); + notifyListeners(); + } + + /// Resets to idle after a success or error so a new sale can begin. + void reset() { + _state = CheckoutIdle([]); + notifyListeners(); + } + + /// Processes payment for the current cart using [method]. + /// + /// Transitions: idle → processing → success | error. + Future processPayment(PaymentMethod method) async { + if (cart.isEmpty) return; + final snapshot = List.from(cart); + _state = CheckoutProcessing(snapshot); + notifyListeners(); + + try { + final result = await paymentService.processPayment(amountCents: totalCents, method: method); + _state = CheckoutSuccess(result); + } on PaymentException catch (e) { + _state = CheckoutError(e.message, snapshot); + } catch (e) { + _state = CheckoutError('Unexpected error: $e', snapshot); + } + notifyListeners(); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/payment_service.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/payment_service.dart new file mode 100644 index 0000000..840aac9 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/payment_service.dart @@ -0,0 +1,30 @@ +import '../domain/payment_method.dart'; +import '../domain/payment_result.dart'; + +/// Abstract contract for processing point-of-sale payments. +/// +/// Implementations: +/// - [FakePaymentService] — always succeeds instantly; used in fake/test mode. +/// - `SquarePaymentService` (future) — delegates to the Square Reader SDK. +/// +/// All amounts are expressed in the smallest currency unit (cents for USD). +abstract class PaymentService { + const PaymentService(); + + /// Processes a payment for [amountCents] using [method]. + /// + /// Returns a [PaymentResult] on success. + /// Throws a [PaymentException] on failure. + Future processPayment({required int amountCents, required PaymentMethod method}); +} + +/// Thrown when a payment attempt fails. +class PaymentException implements Exception { + final String message; + final Object? cause; + + const PaymentException(this.message, {this.cause}); + + @override + String toString() => 'PaymentException: $message${cause != null ? ' (cause: $cause)' : ''}'; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_payment_service.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_payment_service.dart new file mode 100644 index 0000000..3ba033c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_payment_service.dart @@ -0,0 +1,32 @@ +import 'dart:math' as math; + +import '../application/payment_service.dart'; +import '../domain/payment_method.dart'; +import '../domain/payment_result.dart'; + +/// In-memory [PaymentService] that always succeeds instantly. +/// +/// Used in `fake` mode and in unit/widget tests. Generates a deterministic +/// transaction ID from a counter so tests can assert on the returned value. +class FakePaymentService extends PaymentService { + int _counter = 0; + + /// Simulates a successful payment with a generated transaction ID. + /// + /// Never throws. Returns immediately (no network delay). + @override + Future processPayment({ + required int amountCents, + required PaymentMethod method, + }) async { + _counter++; + final id = 'fake-txn-${_counter.toString().padLeft(4, '0')}-${math.Random().nextInt(9999)}'; + return PaymentResult( + transactionId: id, + amountCents: amountCents, + method: method, + completedAt: DateTime.now().toUtc(), + receiptUrl: method == PaymentMethod.card ? 'https://squareup.com/receipt/$id' : null, + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/payment_method.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/payment_method.dart new file mode 100644 index 0000000..bf36bff --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/payment_method.dart @@ -0,0 +1,30 @@ +/// The method used to complete a point-of-sale payment. +enum PaymentMethod { + /// Cash tendered by the customer; no hardware required. + cash, + + /// Card payment processed via Square Reader SDK or manual entry. + card, +} + +extension PaymentMethodLabel on PaymentMethod { + /// Human-readable label for display in the UI. + String get label { + switch (this) { + case PaymentMethod.cash: + return 'Cash'; + case PaymentMethod.card: + return 'Card'; + } + } + + /// Icon name hint for the UI layer. + String get iconHint { + switch (this) { + case PaymentMethod.cash: + return 'payments'; + case PaymentMethod.card: + return 'credit_card'; + } + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/payment_result.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/payment_result.dart new file mode 100644 index 0000000..865945a --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/payment_result.dart @@ -0,0 +1,59 @@ +import 'payment_method.dart'; + +/// The outcome of a completed point-of-sale payment transaction. +/// +/// Returned by [PaymentService.processPayment] after a successful charge. +/// Immutable value object — all fields are set at construction time. +class PaymentResult { + /// Unique transaction identifier assigned by the payment processor. + /// + /// For cash payments this is a locally-generated UUID. + /// For card payments this is the Square payment ID. + final String transactionId; + + /// Total amount charged, in the smallest currency unit (e.g. cents for USD). + final int amountCents; + + /// The payment method used. + final PaymentMethod method; + + /// URL to the digital receipt, or `null` for cash transactions. + final String? receiptUrl; + + /// UTC timestamp when the payment was completed. + final DateTime completedAt; + + const PaymentResult({ + required this.transactionId, + required this.amountCents, + required this.method, + required this.completedAt, + this.receiptUrl, + }); + + /// Convenience: amount as a decimal dollars string (e.g. `"12.50"`). + String get amountFormatted { + final dollars = amountCents ~/ 100; + final cents = amountCents % 100; + return '\$$dollars.${cents.toString().padLeft(2, '0')}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PaymentResult && + runtimeType == other.runtimeType && + transactionId == other.transactionId && + amountCents == other.amountCents && + method == other.method && + receiptUrl == other.receiptUrl && + completedAt == other.completedAt; + + @override + int get hashCode => Object.hash(transactionId, amountCents, method, receiptUrl, completedAt); + + @override + String toString() => + 'PaymentResult(transactionId: $transactionId, amount: $amountFormatted, ' + 'method: ${method.label}, completedAt: $completedAt)'; +} diff --git a/kell_creations_apps/packages/feature_orders/test/checkout_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/checkout_controller_test.dart new file mode 100644 index 0000000..0d630a1 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/checkout_controller_test.dart @@ -0,0 +1,159 @@ +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A [PaymentService] that always throws [PaymentException]. +class _FailingPaymentService extends PaymentService { + @override + Future processPayment({ + required int amountCents, + required PaymentMethod method, + }) async { + throw const PaymentException('Card declined'); + } +} + +const _item1 = OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00); +const _item2 = OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50); + +void main() { + group('CheckoutController', () { + late CheckoutController controller; + + setUp(() { + controller = CheckoutController(paymentService: FakePaymentService()); + }); + + tearDown(() => controller.dispose()); + + // ── initial state ──────────────────────────────────────────────────────── + + test('starts in idle state with empty cart', () { + expect(controller.state, isA()); + expect(controller.cart, isEmpty); + expect(controller.totalCents, 0); + }); + + // ── addItem ────────────────────────────────────────────────────────────── + + test('addItem adds a new entry to the cart', () { + controller.addItem(_item1); + expect(controller.cart.length, 1); + expect(controller.cart.first.item.sku, 'WA-001'); + expect(controller.cart.first.quantity, 1); + }); + + test('addItem increments quantity for duplicate SKU', () { + controller.addItem(_item1); + controller.addItem(_item1); + expect(controller.cart.length, 1); + expect(controller.cart.first.quantity, 2); + }); + + test('addItem handles multiple distinct items', () { + controller.addItem(_item1); + controller.addItem(_item2); + expect(controller.cart.length, 2); + }); + + // ── totalCents ─────────────────────────────────────────────────────────── + + test('totalCents sums all line totals', () { + controller.addItem(_item1); // 10.00 + controller.addItem(_item2); // 5.50 + // 1000 + 550 = 1550 + expect(controller.totalCents, 1550); + }); + + test('totalCents accounts for quantity', () { + controller.addItem(_item1); + controller.addItem(_item1); // qty 2 × $10 = $20 + expect(controller.totalCents, 2000); + }); + + // ── removeItem ─────────────────────────────────────────────────────────── + + test('removeItem decrements quantity', () { + controller.addItem(_item1); + controller.addItem(_item1); + controller.removeItem('WA-001'); + expect(controller.cart.first.quantity, 1); + }); + + test('removeItem removes entry when quantity reaches zero', () { + controller.addItem(_item1); + controller.removeItem('WA-001'); + expect(controller.cart, isEmpty); + }); + + test('removeItem is a no-op for unknown SKU', () { + controller.addItem(_item1); + controller.removeItem('UNKNOWN'); + expect(controller.cart.length, 1); + }); + + // ── clearCart ──────────────────────────────────────────────────────────── + + test('clearCart empties the cart', () { + controller.addItem(_item1); + controller.addItem(_item2); + controller.clearCart(); + expect(controller.cart, isEmpty); + expect(controller.totalCents, 0); + }); + + // ── processPayment — success ───────────────────────────────────────────── + + test('processPayment transitions to CheckoutSuccess on success', () async { + controller.addItem(_item1); + await controller.processPayment(PaymentMethod.cash); + expect(controller.state, isA()); + final success = controller.state as CheckoutSuccess; + expect(success.result.amountCents, 1000); + expect(success.result.method, PaymentMethod.cash); + }); + + test('processPayment is a no-op when cart is empty', () async { + await controller.processPayment(PaymentMethod.cash); + expect(controller.state, isA()); + }); + + // ── processPayment — failure ───────────────────────────────────────────── + + test('processPayment transitions to CheckoutError on PaymentException', () async { + final failCtrl = CheckoutController(paymentService: _FailingPaymentService()); + addTearDown(failCtrl.dispose); + failCtrl.addItem(_item1); + await failCtrl.processPayment(PaymentMethod.card); + expect(failCtrl.state, isA()); + final err = failCtrl.state as CheckoutError; + expect(err.message, 'Card declined'); + // Cart is preserved after error + expect(err.cart.length, 1); + }); + + // ── reset ──────────────────────────────────────────────────────────────── + + test('reset returns to idle with empty cart after success', () async { + controller.addItem(_item1); + await controller.processPayment(PaymentMethod.cash); + expect(controller.state, isA()); + controller.reset(); + expect(controller.state, isA()); + expect(controller.cart, isEmpty); + }); + + // ── CartEntry ──────────────────────────────────────────────────────────── + + test('CartEntry.lineTotalCents is quantity × unitPrice × 100', () { + const entry = CartEntry(item: _item1, quantity: 3); + expect(entry.lineTotalCents, 3000); // 3 × $10.00 + }); + + test('CartEntry.copyWith updates quantity', () { + const entry = CartEntry(item: _item1, quantity: 1); + final updated = entry.copyWith(quantity: 5); + expect(updated.quantity, 5); + expect(updated.item.sku, 'WA-001'); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/fake_payment_service_test.dart b/kell_creations_apps/packages/feature_orders/test/fake_payment_service_test.dart new file mode 100644 index 0000000..85b9fe8 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/fake_payment_service_test.dart @@ -0,0 +1,41 @@ +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FakePaymentService', () { + late FakePaymentService service; + + setUp(() => service = FakePaymentService()); + + test('returns a PaymentResult for cash', () async { + final result = await service.processPayment(amountCents: 1000, method: PaymentMethod.cash); + expect(result.amountCents, 1000); + expect(result.method, PaymentMethod.cash); + expect(result.receiptUrl, isNull); + expect(result.transactionId, isNotEmpty); + }); + + test('returns a PaymentResult for card with receiptUrl', () async { + final result = await service.processPayment(amountCents: 2500, method: PaymentMethod.card); + expect(result.amountCents, 2500); + expect(result.method, PaymentMethod.card); + expect(result.receiptUrl, isNotNull); + expect(result.receiptUrl, contains('squareup.com')); + }); + + test('generates unique transaction IDs across calls', () async { + final r1 = await service.processPayment(amountCents: 100, method: PaymentMethod.cash); + final r2 = await service.processPayment(amountCents: 100, method: PaymentMethod.cash); + // IDs include a counter prefix that differs + expect(r1.transactionId, isNot(equals(r2.transactionId))); + }); + + test('completedAt is set to a recent UTC time', () async { + final before = DateTime.now().toUtc().subtract(const Duration(seconds: 1)); + final result = await service.processPayment(amountCents: 500, method: PaymentMethod.cash); + final after = DateTime.now().toUtc().add(const Duration(seconds: 1)); + expect(result.completedAt.isAfter(before), isTrue); + expect(result.completedAt.isBefore(after), isTrue); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/payment_method_test.dart b/kell_creations_apps/packages/feature_orders/test/payment_method_test.dart new file mode 100644 index 0000000..b013f10 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/payment_method_test.dart @@ -0,0 +1,26 @@ +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PaymentMethod', () { + test('cash has correct label', () { + expect(PaymentMethod.cash.label, 'Cash'); + }); + + test('card has correct label', () { + expect(PaymentMethod.card.label, 'Card'); + }); + + test('cash has payments iconHint', () { + expect(PaymentMethod.cash.iconHint, 'payments'); + }); + + test('card has credit_card iconHint', () { + expect(PaymentMethod.card.iconHint, 'credit_card'); + }); + + test('all values covered', () { + expect(PaymentMethod.values.length, 2); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/payment_result_test.dart b/kell_creations_apps/packages/feature_orders/test/payment_result_test.dart new file mode 100644 index 0000000..50bf604 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/payment_result_test.dart @@ -0,0 +1,77 @@ +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final baseTime = DateTime.utc(2026, 7, 25, 14, 30); + + PaymentResult makeResult({ + String transactionId = 'txn-001', + int amountCents = 1250, + PaymentMethod method = PaymentMethod.cash, + String? receiptUrl, + }) { + return PaymentResult( + transactionId: transactionId, + amountCents: amountCents, + method: method, + completedAt: baseTime, + receiptUrl: receiptUrl, + ); + } + + group('PaymentResult', () { + test('amountFormatted formats cents correctly', () { + expect(makeResult(amountCents: 1250).amountFormatted, r'$12.50'); + }); + + test('amountFormatted pads single-digit cents', () { + expect(makeResult(amountCents: 1205).amountFormatted, r'$12.05'); + }); + + test('amountFormatted handles zero cents', () { + expect(makeResult(amountCents: 0).amountFormatted, r'$0.00'); + }); + + test('amountFormatted handles whole dollars', () { + expect(makeResult(amountCents: 2000).amountFormatted, r'$20.00'); + }); + + test('equality — same fields are equal', () { + final a = makeResult(); + final b = makeResult(); + expect(a, equals(b)); + }); + + test('equality — different transactionId not equal', () { + final a = makeResult(transactionId: 'txn-001'); + final b = makeResult(transactionId: 'txn-002'); + expect(a, isNot(equals(b))); + }); + + test('equality — different amountCents not equal', () { + final a = makeResult(amountCents: 100); + final b = makeResult(amountCents: 200); + expect(a, isNot(equals(b))); + }); + + test('receiptUrl is null for cash by default', () { + expect(makeResult(method: PaymentMethod.cash).receiptUrl, isNull); + }); + + test('receiptUrl is set for card', () { + final r = makeResult(method: PaymentMethod.card, receiptUrl: 'https://example.com/receipt'); + expect(r.receiptUrl, 'https://example.com/receipt'); + }); + + test('toString contains transactionId', () { + final r = makeResult(transactionId: 'txn-abc'); + expect(r.toString(), contains('txn-abc')); + }); + + test('hashCode is consistent', () { + final a = makeResult(); + final b = makeResult(); + expect(a.hashCode, equals(b.hashCode)); + }); + }); +}