From 116255f8ac00e11871a19bbc907c14c4d8c638a9 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 25 Jul 2026 16:55:46 -0400 Subject: [PATCH] feat(stage-10F): SquareSyncStatusPanel + IntegrationsPage + MobileIntegrationsPage + squareSyncController wired into both shells --- docs/development/build_execution_tracker.md | 28 +- docs/development/master_development_brief.md | 65 ++-- .../lib/composition/mobile_app_services.dart | 29 +- .../lib/pages/mobile_integrations_page.dart | 64 ++++ .../kell_mobile/lib/shell/mobile_shell.dart | 5 +- .../composition/mobile_app_services_test.dart | 7 + .../pages/mobile_integrations_page_test.dart | 53 ++++ .../lib/composition/app_services.dart | 29 +- .../kell_web/lib/pages/integrations_page.dart | 56 ++++ .../apps/kell_web/lib/routing/app_routes.dart | 8 +- .../test/composition/app_services_test.dart | 8 + .../test/pages/integrations_page_test.dart | 53 ++++ .../lib/feature_wordpress.dart | 1 + .../widgets/square_sync_status_panel.dart | 278 ++++++++++++++++++ .../test/square_sync_status_panel_test.dart | 140 +++++++++ 15 files changed, 786 insertions(+), 38 deletions(-) create mode 100644 kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart create mode 100644 kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart create mode 100644 kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/square_sync_status_panel_test.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index a7a326a..506e8c5 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -5,9 +5,9 @@ - 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 -- 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) +- active branch: `feat/square-sync-ui` (Stage 10F) — **ready for merge**; `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, `squareSyncController` wired into both shells +- next branch: `feat/square-sync-orders` (Stage 11) — order sync from Square to WooCommerce +- total tests on main: 651/651 passing on Stage 10F branch (545 feature_wordpress + 52 kell_web + 54 kell_mobile); dart analyze clean across all packages ## Slice tracker @@ -652,6 +652,28 @@ - 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`) diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index ae8d7b0..031267a 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -128,6 +128,8 @@ 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` @@ -186,33 +188,56 @@ 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 + ### Next recommended branch -> **⚠️ 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`. +**`feat/square-mobile-pos`** — Stage 10C: Square Mobile POS / Checkout (P2.1) -**`feat/square-catalog-sync-service-controller`** — Stage 10E: `SquareSyncController` (highest-priority blocking task from Phase 1 audit backlog). +Branch from latest `main` **after** merging all pending branches (10D, 10E, 10F). This slice adds in-person payment processing capability to the Android app: -Branch from latest `main` **after** merging Stage 10D. This slice adds a `ChangeNotifier` controller (or Riverpod `AsyncNotifier` if Riverpod is wired) that: +- Create `PaymentService` abstraction in application layer +- Create `PaymentResult` domain model (amount, method, transactionId, receiptUrl) +- Create `MobileCheckoutPage` — product selection → cart → payment flow +- Support cash recording (manual entry, no Square hardware needed) +- Support card payment via Square Reader SDK +- After payment: create order record, deduct inventory +- Add `Sales` tab to mobile navigation -- 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 +**Alternatively**, if POS is deferred, the next highest-value branch is: -**Also include in this branch (P1.1 + P1.5):** +**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data -- 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. +- 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 --- diff --git a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart index ed1b0b3..5cccb4d 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart @@ -19,11 +19,17 @@ 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; + const MobileAppServices({ required this.inventoryRepository, required this.ordersRepository, required this.policyRepository, required this.productPublishingRepository, + this.squareSyncController, }); /// Creates a [MobileAppServices] backed by fake, in-memory repositories. @@ -73,14 +79,19 @@ 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: SquareCatalogRepository( - apiClient: apiClient, - locationId: locationId, - ), + productPublishingRepository: catalogRepo, + squareSyncController: SquareSyncController(syncService: syncService), ); } @@ -108,6 +119,13 @@ 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, @@ -115,7 +133,8 @@ class MobileAppServices extends KcAppServices { ), ordersRepository: FakeOrdersRepository(), policyRepository: FakePolicyRepository(), - productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient), + productPublishingRepository: wcPublishingRepo, + squareSyncController: SquareSyncController(syncService: syncService), ); } diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart new file mode 100644 index 0000000..5c71eed --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart @@ -0,0 +1,64 @@ +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), + ], + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index 401475c..10bacea 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -7,7 +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/integrations_placeholder_page.dart'; +import '../pages/mobile_integrations_page.dart'; import '../pages/mobile_inventory_page.dart'; import '../pages/mobile_orders_page.dart'; import '../pages/mobile_publishing_page.dart'; @@ -192,11 +192,12 @@ class _MorePage extends StatelessWidget { title: const Text('Integrations'), trailing: const Icon(Icons.chevron_right), onTap: () { + final services = KcAppScope.of(context); Navigator.of(context).push( MaterialPageRoute( builder: (_) => Scaffold( appBar: AppBar(title: const Text('Integrations')), - body: const IntegrationsPlaceholderPage(), + body: MobileIntegrationsPage(squareSyncController: services.squareSyncController), ), ), ); diff --git a/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart b/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart index 15e5536..145351d 100644 --- a/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart +++ b/kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart @@ -75,6 +75,13 @@ void main() { expect(services.productPublishingRepository, isA()); }); + test('squareSyncController is non-null', () { + final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123'); + expect(services.squareSyncController, isNotNull); + expect(services.squareSyncController, isA()); + services.squareSyncController!.dispose(); + }); + test('defaults to sandbox environment', () { final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123'); expect(services, isA()); diff --git a/kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart new file mode 100644 index 0000000..66a98ef --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart @@ -0,0 +1,53 @@ +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); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart index 1c96b30..2632f06 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart @@ -23,11 +23,17 @@ 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. @@ -81,14 +87,19 @@ 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: SquareCatalogRepository( - apiClient: apiClient, - locationId: locationId, - ), + productPublishingRepository: catalogRepo, + squareSyncController: SquareSyncController(syncService: syncService), ); } @@ -115,6 +126,13 @@ 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, @@ -122,7 +140,8 @@ class AppServices extends KcAppServices { ), ordersRepository: FakeOrdersRepository(), policyRepository: FakePolicyRepository(), - productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient), + productPublishingRepository: wcPublishingRepo, + squareSyncController: SquareSyncController(syncService: syncService), ); } diff --git a/kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart b/kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart new file mode 100644 index 0000000..d9a09f9 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart @@ -0,0 +1,56 @@ +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), + ], + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart index c8c9c84..50cfb96 100644 --- a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -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_placeholder_page.dart'; +import '../pages/integrations_page.dart'; import '../shell/app_shell.dart'; abstract final class AppRoutes { @@ -117,10 +117,12 @@ abstract final class AppRoutes { case integrations: return _buildRoute( settings, - (context) => const AppShell( + (context) => AppShell( selectedRoute: integrations, title: 'Integrations', - child: IntegrationsPlaceholderPage(), + child: IntegrationsPage( + squareSyncController: AppScope.of(context).squareSyncController, + ), ), ); default: diff --git a/kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart b/kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart index b10e0e3..ca408bf 100644 --- a/kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart +++ b/kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart @@ -75,6 +75,13 @@ void main() { expect(services.productPublishingRepository, isA()); }); + test('squareSyncController is non-null', () { + final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123'); + expect(services.squareSyncController, isNotNull); + expect(services.squareSyncController, isA()); + 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'); @@ -136,6 +143,7 @@ void main() { final services = factory.createFake(); expect(services.inventoryRepository, isA()); expect(services.productPublishingRepository, isA()); + expect(services.squareSyncController, isNull); }); test('createWordPress returns wordpress services', () { diff --git a/kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart b/kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart new file mode 100644 index 0000000..2bb0794 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart @@ -0,0 +1,53 @@ +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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 323ed1b..8932a28 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -35,6 +35,7 @@ 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'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart new file mode 100644 index 0000000..6e218b2 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart @@ -0,0 +1,278 @@ +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 createState() => _SquareSyncStatusPanelState(); +} + +class _SquareSyncStatusPanelState extends State { + @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), + ), + ), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/square_sync_status_panel_test.dart b/kell_creations_apps/packages/feature_wordpress/test/square_sync_status_panel_test.dart new file mode 100644 index 0000000..1ffef58 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/square_sync_status_panel_test.dart @@ -0,0 +1,140 @@ +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 _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(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(); + }); + }); +}