Compare commits

..

No commits in common. "7bf8f55a3e7e1432ade9362a45da8f7b9088db26" and "94c62931fdb8836b217963d81e4ef72e3a3b6640" have entirely different histories.

18 changed files with 27 additions and 1395 deletions

View File

@ -6,42 +6,12 @@
- 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: `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
- 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
## 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
@ -723,4 +693,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

View File

@ -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`: `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 `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 `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`: `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
- 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)
> **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,25 +216,22 @@ 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

View File

@ -5,16 +5,6 @@ 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
@ -34,23 +24,12 @@ 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<OrderItem> 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.
@ -60,7 +39,6 @@ class MobileAppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
paymentService: FakePaymentService(),
);
}
@ -83,7 +61,6 @@ class MobileAppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
paymentService: FakePaymentService(),
);
}
@ -115,7 +92,6 @@ class MobileAppServices extends KcAppServices {
policyRepository: FakePolicyRepository(),
productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(),
);
}
@ -159,7 +135,6 @@ class MobileAppServices extends KcAppServices {
policyRepository: FakePolicyRepository(),
productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(),
);
}

View File

@ -1,471 +0,0 @@
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<OrderItem> catalogue;
const MobileCheckoutPage({super.key, required this.controller, required this.catalogue});
@override
State<MobileCheckoutPage> createState() => _MobileCheckoutPageState();
}
class _MobileCheckoutPageState extends State<MobileCheckoutPage> {
// 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<OrderItem> 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<CartEntry> 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<void> _confirmPayment(BuildContext context, PaymentMethod method) async {
final confirmed = await showDialog<bool>(
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),
),
),
],
),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:core/core.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart';
import 'package:flutter/material.dart';
@ -8,7 +7,6 @@ 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';
@ -35,9 +33,8 @@ class _MobileShellState extends State<MobileShell> {
/// 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', 'Sales', 'More'];
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More'];
@override
void didChangeDependencies() {
@ -53,7 +50,6 @@ class _MobileShellState extends State<MobileShell> {
ordersRepository: services.ordersRepository,
),
);
_checkoutController = CheckoutController(paymentService: services.paymentService);
_dashboardControllerInitialised = true;
}
}
@ -63,7 +59,6 @@ class _MobileShellState extends State<MobileShell> {
@override
void dispose() {
_dashboardController.dispose();
_checkoutController.dispose();
super.dispose();
}
@ -106,11 +101,6 @@ class _MobileShellState extends State<MobileShell> {
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),
@ -148,11 +138,6 @@ class _MobileShellState extends State<MobileShell> {
},
);
case 4:
return MobileCheckoutPage(
controller: _checkoutController,
catalogue: services.checkoutCatalogue,
);
case 5:
return const _MorePage();
default:
return const Center(child: Text('Unknown section'));

View File

@ -1,7 +1,5 @@
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';
@ -244,73 +242,5 @@ void main() {
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// paymentService composition (Stage 10C)
group('paymentService', () {
test('fake() provides a FakePaymentService', () {
final services = MobileAppServices.fake();
expect(services.paymentService, isA<FakePaymentService>());
});
test('wordpress() provides a FakePaymentService', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.paymentService, isA<FakePaymentService>());
});
test('square() provides a FakePaymentService', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.paymentService, isA<FakePaymentService>());
});
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<FakePaymentService>());
});
});
// 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');
});
});
});
}

View File

@ -1,156 +0,0 @@
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<OrderItem> 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);
});
});
}

View File

@ -42,13 +42,12 @@ void main() {
expect(find.text('FAKE'), findsOneWidget);
});
testWidgets('bottom navigation bar has 6 destinations', (WidgetTester tester) async {
testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
// Dashboard, Inventory, Products, Orders, Sales, More
expect(find.byType(NavigationDestination), findsNWidgets(6));
expect(find.byType(NavigationDestination), findsNWidgets(5));
});
testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async {

View File

@ -1,14 +1,11 @@
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';
@ -17,8 +14,6 @@ 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';

View File

@ -1,138 +0,0 @@
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<CartEntry> cart;
CheckoutIdle(this.cart);
}
/// A payment is being processed.
class CheckoutProcessing extends CheckoutState {
final List<CartEntry> 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<CartEntry> 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<CartEntry> 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<CartEntry>.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<CartEntry>.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<void> processPayment(PaymentMethod method) async {
if (cart.isEmpty) return;
final snapshot = List<CartEntry>.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();
}
}

View File

@ -1,30 +0,0 @@
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<PaymentResult> 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)' : ''}';
}

View File

@ -1,32 +0,0 @@
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<PaymentResult> 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,
);
}
}

View File

@ -1,30 +0,0 @@
/// 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';
}
}
}

View File

@ -1,59 +0,0 @@
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)';
}

View File

@ -1,159 +0,0 @@
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<PaymentResult> 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<CheckoutIdle>());
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<CheckoutSuccess>());
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<CheckoutIdle>());
});
// 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<CheckoutError>());
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<CheckoutSuccess>());
controller.reset();
expect(controller.state, isA<CheckoutIdle>());
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');
});
});
}

View File

@ -1,41 +0,0 @@
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);
});
});
}

View File

@ -1,26 +0,0 @@
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);
});
});
}

View File

@ -1,77 +0,0 @@
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));
});
});
}