Compare commits
No commits in common. "main" and "feat/square-catalog-sync-service-controller" have entirely different histories.
main
...
feat/squar
|
|
@ -5,43 +5,12 @@
|
|||
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
|
||||
- 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: `feat/square-catalog-sync-service-controller` (Stage 10E) — **ready for merge**; all P1.1–P1.5 items complete
|
||||
- next branch: `feat/square-sync-ui` (Stage 10F) — Square sync status panel + `SquareSyncController` wired into `kell_web` and `kell_mobile` shells
|
||||
- total tests on main: 834/834 passing (Stage 10E baseline: 537 feature_wordpress + 47 kell_web + 49 kell_mobile + 34 core + 167 feature_inventory = 834 across tested 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
|
||||
|
|
@ -683,28 +652,6 @@
|
|||
- analyze: clean
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-sync-ui
|
||||
|
||||
- status: ready for merge (branch `feat/square-sync-ui`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10F — Square sync UI surface)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart` — new `SquareSyncStatusPanel` `StatefulWidget`; listens to `SquareSyncController`; shows status chip (`Never synced` / `OK` / `Partial` / `Error`), last-synced timestamp, `Syncing…` progress indicator during sync, `Sync Now` `FilledButton` (disabled while syncing), and last-sync-result section with Catalog/Inventory row counts when `lastResult` is non-null
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_status_panel.dart` to barrel exports (Presentation section)
|
||||
- `kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart` — new `IntegrationsPage`; shows `SquareSyncStatusPanel` when `squareSyncController` is non-null; shows placeholder card with hub icon and "No integrations configured" message otherwise; "Square" section label when controller is present
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart` — new `MobileIntegrationsPage`; mirrors `IntegrationsPage` layout for mobile; `SafeArea` wrapper
|
||||
- `kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart` — added `AppRoute.integrations` case routing to `IntegrationsPage(squareSyncController: services.squareSyncController)`
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart` — More tab (case 4) switched from `IntegrationsPlaceholderPage` to `MobileIntegrationsPage(squareSyncController: services.squareSyncController)`
|
||||
- `feature_wordpress/test/square_sync_status_panel_test.dart` — 8 new widget tests: renders title, shows Never synced chip, shows Last synced: Never, Sync Now button enabled, shows OK chip after sync, shows last sync result section, rebuilds on notify, unregisters listener on dispose; uses `tester.pump(Duration)` to advance fake time through `Future.delayed` timers
|
||||
- `kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart` — 4 new widget tests: placeholder when controller null, shows `SquareSyncStatusPanel` when controller provided, shows Square section label, placeholder shows hub icon
|
||||
- `kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart` — 4 new widget tests: mirroring kell_web integrations page tests for mobile
|
||||
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — expanded to 28 tests (added `squareSyncController` non-null assertion for `square()` factory, `multi()` factory tests, full `KcBootstrap` credential validation tests)
|
||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — expanded to 28 tests (mirrored kell_web composition tests)
|
||||
- tests: passed (545/545 feature_wordpress ← +8 new; 52/52 kell_web ← +5 new; 54/54 kell_mobile ← +5 new — 651 total across tested packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service-controller
|
||||
|
||||
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
|
||||
|
|
|
|||
|
|
@ -128,8 +128,6 @@ Rules:
|
|||
- ✅ Square API client and authentication activated (Stage 10A complete — merged `feat/square-api-client` → `main`, 2026-07-19).
|
||||
- ✅ Square catalog and inventory sync mappers + ProductIdMapping domain landed (Stage 10B complete — merged `feat/square-catalog-sync` → `main`, 2026-07-25).
|
||||
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25).
|
||||
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
|
||||
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -148,15 +146,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.
|
||||
|
||||
|
|
@ -188,59 +186,33 @@ Delivered:
|
|||
- Exported from `feature_wordpress.dart` barrel
|
||||
- 31 new tests (506/506 feature_wordpress total, 167/167 feature_inventory — all passing, analyze clean)
|
||||
|
||||
### Stage 10E — complete (2026-07-25)
|
||||
|
||||
**`feat/square-catalog-sync-service-controller`** — `SquareSyncController` + `AppServices.square()`/`multi()` + ID conflation fix + `KcBootstrap` credential validation
|
||||
|
||||
Delivered:
|
||||
|
||||
- `SquareSyncController` `ChangeNotifier` in `feature_wordpress/lib/src/application/square_sync_controller.dart`
|
||||
- `AppServices.square()` and `AppServices.multi()` factory constructors (P1.1)
|
||||
- `MobileAppServices.square()` and `MobileAppServices.multi()` factory constructors (P1.1)
|
||||
- `KcBootstrap` Square credential validation with fallback to fake mode (P1.5)
|
||||
- `ProductDraft.squareCatalogObjectId` nullable field; ID conflation bug fixed in `syncCatalog()` (P1.2)
|
||||
- 18 new `SquareSyncController` tests + 10 new composition tests per app (834 total across tested packages)
|
||||
- Analyze clean
|
||||
|
||||
### Stage 10F — complete (2026-07-25)
|
||||
|
||||
**`feat/square-sync-ui`** — `SquareSyncStatusPanel` + `IntegrationsPage` + `MobileIntegrationsPage`
|
||||
|
||||
Delivered:
|
||||
|
||||
- `SquareSyncStatusPanel` `StatefulWidget` in `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart`
|
||||
- `IntegrationsPage` in `kell_web/lib/pages/integrations_page.dart` — shows panel when `squareSyncController` is non-null, placeholder otherwise
|
||||
- `MobileIntegrationsPage` in `kell_mobile/lib/pages/mobile_integrations_page.dart` — mirrors web layout for mobile
|
||||
- `AppRoute.integrations` wired in `kell_web` routing
|
||||
- More tab in `kell_mobile` shell switched from placeholder to `MobileIntegrationsPage`
|
||||
- 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/custom-order-domain`** — Stage 11A: Custom order domain and data
|
||||
> **⚠️ Prerequisite (P1.3):** Merge `feat/square-catalog-sync-service` → `main` before starting Stage 10E. Stage 10D is complete and ready for merge; Stage 10E depends on it being on `main`.
|
||||
|
||||
- Add `OrderType` enum, `CustomOrderSpec`, `DepositInfo` value objects
|
||||
- Extend `Order` model and `OrdersRepository` contract
|
||||
- Implement in `FakeOrdersRepository` and `WooCommerceOrdersRepository`
|
||||
- Tests for all new domain models and repository methods
|
||||
**`feat/square-catalog-sync-service-controller`** — Stage 10E: `SquareSyncController` (highest-priority blocking task from Phase 1 audit backlog).
|
||||
|
||||
Branch from latest `main` **after** merging Stage 10D. This slice adds a `ChangeNotifier` controller (or Riverpod `AsyncNotifier` if Riverpod is wired) that:
|
||||
|
||||
- Wraps `SquareCatalogSyncService` and exposes `syncAll()`, `syncCatalog()`, `syncInventory()` as async actions
|
||||
- Tracks `isSyncing` state and the last `SquareSyncResult`
|
||||
- Exposes a `lastSyncedAt` timestamp
|
||||
- Lives in `feature_wordpress/lib/src/application/square_sync_controller.dart`
|
||||
- Is exported from `feature_wordpress.dart` barrel
|
||||
- Wires into `AppServices` and `MobileAppServices` so the sync can be triggered from the UI
|
||||
|
||||
**Also include in this branch (P1.1 + P1.5):**
|
||||
|
||||
- Add `AppServices.square()` and `AppServices.multi()` factory constructors using `SquareCatalogRepository` and `SquareInventoryRepository`
|
||||
- Mirror in `MobileAppServices.square()` and `MobileAppServices.multi()`
|
||||
- Update `KcServiceFactory` to accept `createSquare` and `createMulti` callbacks
|
||||
- Add `hasSquareConfig` credential check in `KcBootstrap` for `square` and `multi` environments
|
||||
|
||||
**Fix in this branch (P1.2):**
|
||||
|
||||
- Resolve `SquareCatalogSyncService.syncCatalog()` ID conflation: add `squareCatalogObjectId` field to `ProductDraft` (nullable), or pass it separately, so the sync service does not conflate local/WooCommerce IDs with Square catalog object IDs
|
||||
|
||||
Tests should cover: initial state, syncAll happy path, syncAll partial failure, isSyncing true during sync and false after, lastSyncedAt stamped on completion, error state on total failure, square/multi factory construction, bootstrap credential validation for square mode.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -29,28 +19,11 @@ class MobileAppServices extends KcAppServices {
|
|||
final PolicyRepository policyRepository;
|
||||
final ProductPublishingRepository productPublishingRepository;
|
||||
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
///
|
||||
/// 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 +33,6 @@ class MobileAppServices extends KcAppServices {
|
|||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: FakeProductPublishingRepository(),
|
||||
paymentService: FakePaymentService(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +55,6 @@ class MobileAppServices extends KcAppServices {
|
|||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||
paymentService: FakePaymentService(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,20 +73,14 @@ class MobileAppServices extends KcAppServices {
|
|||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
|
||||
|
||||
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: catalogRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: catalogRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
paymentService: FakePaymentService(),
|
||||
productPublishingRepository: SquareCatalogRepository(
|
||||
apiClient: apiClient,
|
||||
locationId: locationId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,13 +108,6 @@ class MobileAppServices extends KcAppServices {
|
|||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
|
||||
|
||||
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: wcPublishingRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: SquareInventoryRepository(
|
||||
apiClient: squareApiClient,
|
||||
|
|
@ -157,9 +115,7 @@ class MobileAppServices extends KcAppServices {
|
|||
),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: wcPublishingRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
paymentService: FakePaymentService(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The Integrations page for `kell_mobile`.
|
||||
///
|
||||
/// When a [SquareSyncController] is provided (i.e. the app is running in
|
||||
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
|
||||
/// operator can trigger and monitor Square catalog + inventory syncs.
|
||||
///
|
||||
/// When no controller is available (fake or wordpress mode), a placeholder
|
||||
/// message is shown instead.
|
||||
class MobileIntegrationsPage extends StatelessWidget {
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const MobileIntegrationsPage({super.key, this.squareSyncController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = squareSyncController;
|
||||
|
||||
if (controller == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No integrations configured',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Set KC_ENV=square or KC_ENV=multi with Square credentials '
|
||||
'to enable the Square sync panel.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Square',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SquareSyncStatusPanel(controller: controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +7,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/integrations_placeholder_page.dart';
|
||||
import '../pages/mobile_inventory_page.dart';
|
||||
import '../pages/mobile_orders_page.dart';
|
||||
import '../pages/mobile_publishing_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'));
|
||||
|
|
@ -207,12 +192,11 @@ class _MorePage extends StatelessWidget {
|
|||
title: const Text('Integrations'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
final services = KcAppScope.of<MobileAppServices>(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Integrations')),
|
||||
body: MobileIntegrationsPage(squareSyncController: services.squareSyncController),
|
||||
body: const IntegrationsPlaceholderPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -77,13 +75,6 @@ void main() {
|
|||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('squareSyncController is non-null', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.squareSyncController, isNotNull);
|
||||
expect(services.squareSyncController, isA<SquareSyncController>());
|
||||
services.squareSyncController!.dispose();
|
||||
});
|
||||
|
||||
test('defaults to sandbox environment', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<MobileAppServices>());
|
||||
|
|
@ -244,73 +235,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/pages/mobile_integrations_page.dart';
|
||||
|
||||
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
|
||||
|
||||
SquareSyncController _fakeController() {
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: FakeProductPublishingRepository(),
|
||||
mappingRepository: FakeProductIdMappingRepository(),
|
||||
);
|
||||
return SquareSyncController(syncService: syncService);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MobileIntegrationsPage', () {
|
||||
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('No integrations configured'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square Sync'), findsOneWidget);
|
||||
expect(find.text('Sync Now'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Square section label when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('placeholder shows hub icon', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -23,17 +23,11 @@ class AppServices extends KcAppServices {
|
|||
final PolicyRepository policyRepository;
|
||||
final ProductPublishingRepository productPublishingRepository;
|
||||
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
///
|
||||
/// Present only in `square` and `multi` environments.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const AppServices({
|
||||
required this.inventoryRepository,
|
||||
required this.ordersRepository,
|
||||
required this.policyRepository,
|
||||
required this.productPublishingRepository,
|
||||
this.squareSyncController,
|
||||
});
|
||||
|
||||
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
||||
|
|
@ -87,19 +81,14 @@ class AppServices extends KcAppServices {
|
|||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
|
||||
|
||||
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: catalogRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return AppServices(
|
||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: catalogRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
productPublishingRepository: SquareCatalogRepository(
|
||||
apiClient: apiClient,
|
||||
locationId: locationId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -126,13 +115,6 @@ class AppServices extends KcAppServices {
|
|||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
|
||||
|
||||
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: wcPublishingRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return AppServices(
|
||||
inventoryRepository: SquareInventoryRepository(
|
||||
apiClient: squareApiClient,
|
||||
|
|
@ -140,8 +122,7 @@ class AppServices extends KcAppServices {
|
|||
),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: wcPublishingRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The Integrations page for `kell_web`.
|
||||
///
|
||||
/// When a [SquareSyncController] is provided (i.e. the app is running in
|
||||
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
|
||||
/// operator can trigger and monitor Square catalog + inventory syncs.
|
||||
///
|
||||
/// When no controller is available (fake or wordpress mode), a placeholder
|
||||
/// message is shown instead.
|
||||
class IntegrationsPage extends StatelessWidget {
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const IntegrationsPage({super.key, this.squareSyncController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = squareSyncController;
|
||||
|
||||
if (controller == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text('No integrations configured', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Set KC_ENV=square or KC_ENV=multi with Square credentials\n'
|
||||
'to enable the Square sync panel.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Square',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SquareSyncStatusPanel(controller: controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import '../dashboard/application/get_dashboard_summary.dart';
|
|||
import '../navigation/app_navigation.dart';
|
||||
import '../pages/dashboard_page.dart';
|
||||
import '../pages/finance_placeholder_page.dart';
|
||||
import '../pages/integrations_page.dart';
|
||||
import '../pages/integrations_placeholder_page.dart';
|
||||
import '../shell/app_shell.dart';
|
||||
|
||||
abstract final class AppRoutes {
|
||||
|
|
@ -117,12 +117,10 @@ abstract final class AppRoutes {
|
|||
case integrations:
|
||||
return _buildRoute(
|
||||
settings,
|
||||
(context) => AppShell(
|
||||
(context) => const AppShell(
|
||||
selectedRoute: integrations,
|
||||
title: 'Integrations',
|
||||
child: IntegrationsPage(
|
||||
squareSyncController: AppScope.of(context).squareSyncController,
|
||||
),
|
||||
child: IntegrationsPlaceholderPage(),
|
||||
),
|
||||
);
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -75,13 +75,6 @@ void main() {
|
|||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('squareSyncController is non-null', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.squareSyncController, isNotNull);
|
||||
expect(services.squareSyncController, isA<SquareSyncController>());
|
||||
services.squareSyncController!.dispose();
|
||||
});
|
||||
|
||||
test('defaults to sandbox environment', () {
|
||||
// Constructing with no environment param should not throw.
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
|
|
@ -143,7 +136,6 @@ void main() {
|
|||
final services = factory.createFake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
expect(services.squareSyncController, isNull);
|
||||
});
|
||||
|
||||
test('createWordPress returns wordpress services', () {
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_web/pages/integrations_page.dart';
|
||||
|
||||
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
|
||||
|
||||
SquareSyncController _fakeController() {
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: FakeProductPublishingRepository(),
|
||||
mappingRepository: FakeProductIdMappingRepository(),
|
||||
);
|
||||
return SquareSyncController(syncService: syncService);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('IntegrationsPage', () {
|
||||
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const IntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('No integrations configured'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square Sync'), findsOneWidget);
|
||||
expect(find.text('Sync Now'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Square section label when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('placeholder shows hub icon', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const IntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)' : ''}';
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ export 'src/application/update_product_price.dart';
|
|||
export 'src/application/update_product_status.dart';
|
||||
|
||||
// Presentation
|
||||
export 'src/presentation/widgets/square_sync_status_panel.dart';
|
||||
export 'src/presentation/category_management_page.dart';
|
||||
export 'src/presentation/product_publishing_page.dart';
|
||||
export 'src/presentation/widgets/create_product_dialog.dart';
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../application/square_sync_controller.dart';
|
||||
import '../../application/square_catalog_sync_service.dart';
|
||||
|
||||
/// A panel that displays the current Square sync status and provides a
|
||||
/// "Sync Now" button to trigger a full catalog + inventory sync.
|
||||
///
|
||||
/// Listens to [SquareSyncController] and rebuilds whenever sync state changes.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// SquareSyncStatusPanel(controller: squareSyncController)
|
||||
/// ```
|
||||
class SquareSyncStatusPanel extends StatefulWidget {
|
||||
final SquareSyncController controller;
|
||||
|
||||
const SquareSyncStatusPanel({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<SquareSyncStatusPanel> createState() => _SquareSyncStatusPanelState();
|
||||
}
|
||||
|
||||
class _SquareSyncStatusPanelState extends State<SquareSyncStatusPanel> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SquareSyncStatusPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onControllerChanged);
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.sync, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('Square Sync', style: theme.textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
_SyncStatusChip(controller: controller),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_LastSyncedRow(controller: controller),
|
||||
const SizedBox(height: 12),
|
||||
if (controller.lastResult != null) ...[
|
||||
_SyncResultSummary(result: controller.lastResult!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (controller.lastError != null) ...[
|
||||
_ErrorBanner(message: controller.lastError!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: controller.isSyncing ? null : () => controller.syncAll(),
|
||||
icon: controller.isSyncing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
label: Text(controller.isSyncing ? 'Syncing…' : 'Sync Now'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small chip showing the current sync phase.
|
||||
class _SyncStatusChip extends StatelessWidget {
|
||||
final SquareSyncController controller;
|
||||
|
||||
const _SyncStatusChip({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
final String label;
|
||||
|
||||
if (controller.isSyncing) {
|
||||
bg = Colors.blue.shade100;
|
||||
fg = Colors.blue.shade900;
|
||||
label = 'Syncing';
|
||||
} else if (controller.lastError != null) {
|
||||
bg = Colors.red.shade100;
|
||||
fg = Colors.red.shade900;
|
||||
label = 'Error';
|
||||
} else if (controller.lastResult != null) {
|
||||
if (controller.lastResult!.allSucceeded) {
|
||||
bg = Colors.green.shade100;
|
||||
fg = Colors.green.shade900;
|
||||
label = 'OK';
|
||||
} else {
|
||||
bg = Colors.orange.shade100;
|
||||
fg = Colors.orange.shade900;
|
||||
label = 'Partial';
|
||||
}
|
||||
} else {
|
||||
bg = Colors.grey.shade200;
|
||||
fg = Colors.grey.shade700;
|
||||
label = 'Never synced';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row showing the last synced timestamp.
|
||||
class _LastSyncedRow extends StatelessWidget {
|
||||
final SquareSyncController controller;
|
||||
|
||||
const _LastSyncedRow({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ts = controller.lastSyncedAt;
|
||||
final label = ts == null ? 'Never' : _formatDateTime(ts);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.access_time, size: 16, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Last synced: $label',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade700),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDateTime(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final m = local.minute.toString().padLeft(2, '0');
|
||||
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} $h:$m';
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary row showing catalog/inventory synced and failed counts.
|
||||
class _SyncResultSummary extends StatelessWidget {
|
||||
final SquareSyncResult result;
|
||||
|
||||
const _SyncResultSummary({required this.result});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
Text('Last sync result', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 6),
|
||||
_ResultRow(label: 'Catalog', synced: result.catalogSynced, failed: result.catalogFailed),
|
||||
const SizedBox(height: 4),
|
||||
_ResultRow(
|
||||
label: 'Inventory',
|
||||
synced: result.inventorySynced,
|
||||
failed: result.inventoryFailed,
|
||||
),
|
||||
if (result.errors.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${result.errors.length} error(s) — see logs for details',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade800),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultRow extends StatelessWidget {
|
||||
final String label;
|
||||
final int synced;
|
||||
final int failed;
|
||||
|
||||
const _ResultRow({required this.label, required this.synced, required this.failed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 72, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
|
||||
Icon(Icons.check_circle_outline, size: 14, color: Colors.green.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text('$synced', style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(width: 12),
|
||||
if (failed > 0) ...[
|
||||
Icon(Icons.error_outline, size: 14, color: Colors.red.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$failed failed',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade700),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Red banner shown when the last sync threw an unexpected exception.
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 16, color: Colors.red.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wraps [widget] in a minimal [MaterialApp] so that [Theme] and [Navigator]
|
||||
/// are available.
|
||||
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
|
||||
|
||||
/// Creates a [SquareSyncController] backed by a [SquareCatalogSyncService]
|
||||
/// that uses fake repositories — no network calls.
|
||||
SquareSyncController _fakeController() {
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: FakeProductPublishingRepository(),
|
||||
mappingRepository: FakeProductIdMappingRepository(),
|
||||
);
|
||||
return SquareSyncController(syncService: syncService);
|
||||
}
|
||||
|
||||
/// Advances fake time enough to drain all [FakeProductPublishingRepository]
|
||||
/// delays (300 ms per call × a few calls) and pumps the widget tree.
|
||||
Future<void> _drainSync(WidgetTester tester) async {
|
||||
// Advance time in steps to drain all fake delays (300 ms each).
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('SquareSyncStatusPanel', () {
|
||||
testWidgets('renders Square Sync title', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square Sync'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Never synced chip when no sync has run', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Never synced'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Last synced: Never when lastSyncedAt is null', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Last synced: Never'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Sync Now button is enabled when not syncing', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('shows OK chip after a successful sync', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
// Trigger sync and advance fake time to drain all delays.
|
||||
await tester.tap(find.text('Sync Now'));
|
||||
await _drainSync(tester);
|
||||
|
||||
expect(find.text('OK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows last sync result section after sync', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Sync Now'));
|
||||
await _drainSync(tester);
|
||||
|
||||
expect(find.text('Last sync result'), findsOneWidget);
|
||||
expect(find.text('Catalog'), findsOneWidget);
|
||||
expect(find.text('Inventory'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rebuilds when controller notifies listeners', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Never synced'), findsOneWidget);
|
||||
|
||||
// Run a sync via the button and advance fake time.
|
||||
await tester.tap(find.text('Sync Now'));
|
||||
await _drainSync(tester);
|
||||
|
||||
expect(find.text('OK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unregisters listener on dispose', (tester) async {
|
||||
final controller = _fakeController();
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
// Replace the widget tree — this disposes the panel.
|
||||
await tester.pumpWidget(_wrap(const SizedBox()));
|
||||
await tester.pump();
|
||||
|
||||
// Notifying after dispose should not throw.
|
||||
expect(() => controller.notifyListeners(), returnsNormally);
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue