Compare commits
3 Commits
a74cf35bd1
...
94c62931fd
| Author | SHA1 | Date |
|---|---|---|
|
|
94c62931fd | |
|
|
93af845021 | |
|
|
116255f8ac |
|
|
@ -5,9 +5,10 @@
|
||||||
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
|
- 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
|
- 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
|
- 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
|
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
|
||||||
- next branch: `feat/square-sync-ui` (Stage 10F) — Square sync status panel + `SquareSyncController` wired into `kell_web` and `kell_mobile` shells
|
- active branch: none — on `main`
|
||||||
- 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)
|
- next branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout; or `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
|
||||||
|
- total tests on main: 651/651 passing (545 feature_wordpress + 52 kell_web + 54 kell_mobile); dart analyze clean across all packages
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -652,6 +653,28 @@
|
||||||
- analyze: clean
|
- analyze: clean
|
||||||
- brief updated: yes
|
- 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
|
### feat/square-catalog-sync-service-controller
|
||||||
|
|
||||||
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
|
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ Rules:
|
||||||
- ✅ Square API client and authentication activated (Stage 10A complete — merged `feat/square-api-client` → `main`, 2026-07-19).
|
- ✅ 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).
|
- ✅ 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).
|
- ✅ `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`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -186,33 +188,56 @@ Delivered:
|
||||||
- Exported from `feature_wordpress.dart` barrel
|
- Exported from `feature_wordpress.dart` barrel
|
||||||
- 31 new tests (506/506 feature_wordpress total, 167/167 feature_inventory — all passing, analyze clean)
|
- 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
|
### 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
|
**Alternatively**, if POS is deferred, the next highest-value branch is:
|
||||||
- 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):**
|
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
|
||||||
|
|
||||||
- Add `AppServices.square()` and `AppServices.multi()` factory constructors using `SquareCatalogRepository` and `SquareInventoryRepository`
|
- Add `OrderType` enum, `CustomOrderSpec`, `DepositInfo` value objects
|
||||||
- Mirror in `MobileAppServices.square()` and `MobileAppServices.multi()`
|
- Extend `Order` model and `OrdersRepository` contract
|
||||||
- Update `KcServiceFactory` to accept `createSquare` and `createMulti` callbacks
|
- Implement in `FakeOrdersRepository` and `WooCommerceOrdersRepository`
|
||||||
- Add `hasSquareConfig` credential check in `KcBootstrap` for `square` and `multi` environments
|
- Tests for all new domain models and repository methods
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,17 @@ class MobileAppServices extends KcAppServices {
|
||||||
final PolicyRepository policyRepository;
|
final PolicyRepository policyRepository;
|
||||||
final ProductPublishingRepository productPublishingRepository;
|
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({
|
const MobileAppServices({
|
||||||
required this.inventoryRepository,
|
required this.inventoryRepository,
|
||||||
required this.ordersRepository,
|
required this.ordersRepository,
|
||||||
required this.policyRepository,
|
required this.policyRepository,
|
||||||
required this.productPublishingRepository,
|
required this.productPublishingRepository,
|
||||||
|
this.squareSyncController,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||||
|
|
@ -73,14 +79,19 @@ class MobileAppServices extends KcAppServices {
|
||||||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||||
: SquareApiClient.sandbox(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(
|
return MobileAppServices(
|
||||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: SquareCatalogRepository(
|
productPublishingRepository: catalogRepo,
|
||||||
apiClient: apiClient,
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
locationId: locationId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +119,13 @@ class MobileAppServices extends KcAppServices {
|
||||||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||||
: SquareApiClient.sandbox(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(
|
return MobileAppServices(
|
||||||
inventoryRepository: SquareInventoryRepository(
|
inventoryRepository: SquareInventoryRepository(
|
||||||
apiClient: squareApiClient,
|
apiClient: squareApiClient,
|
||||||
|
|
@ -115,7 +133,8 @@ class MobileAppServices extends KcAppServices {
|
||||||
),
|
),
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient),
|
productPublishingRepository: wcPublishingRepo,
|
||||||
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import '../dashboard/application/dashboard_controller.dart';
|
||||||
import '../dashboard/application/get_dashboard_summary.dart';
|
import '../dashboard/application/get_dashboard_summary.dart';
|
||||||
import '../pages/dashboard_page.dart';
|
import '../pages/dashboard_page.dart';
|
||||||
import '../pages/finance_placeholder_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_inventory_page.dart';
|
||||||
import '../pages/mobile_orders_page.dart';
|
import '../pages/mobile_orders_page.dart';
|
||||||
import '../pages/mobile_publishing_page.dart';
|
import '../pages/mobile_publishing_page.dart';
|
||||||
|
|
@ -192,11 +192,12 @@ class _MorePage extends StatelessWidget {
|
||||||
title: const Text('Integrations'),
|
title: const Text('Integrations'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final services = KcAppScope.of<MobileAppServices>(context);
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => Scaffold(
|
builder: (_) => Scaffold(
|
||||||
appBar: AppBar(title: const Text('Integrations')),
|
appBar: AppBar(title: const Text('Integrations')),
|
||||||
body: const IntegrationsPlaceholderPage(),
|
body: MobileIntegrationsPage(squareSyncController: services.squareSyncController),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ void main() {
|
||||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
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', () {
|
test('defaults to sandbox environment', () {
|
||||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||||
expect(services, isA<MobileAppServices>());
|
expect(services, isA<MobileAppServices>());
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -23,11 +23,17 @@ class AppServices extends KcAppServices {
|
||||||
final PolicyRepository policyRepository;
|
final PolicyRepository policyRepository;
|
||||||
final ProductPublishingRepository productPublishingRepository;
|
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({
|
const AppServices({
|
||||||
required this.inventoryRepository,
|
required this.inventoryRepository,
|
||||||
required this.ordersRepository,
|
required this.ordersRepository,
|
||||||
required this.policyRepository,
|
required this.policyRepository,
|
||||||
required this.productPublishingRepository,
|
required this.productPublishingRepository,
|
||||||
|
this.squareSyncController,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
||||||
|
|
@ -81,14 +87,19 @@ class AppServices extends KcAppServices {
|
||||||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||||
: SquareApiClient.sandbox(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(
|
return AppServices(
|
||||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: SquareCatalogRepository(
|
productPublishingRepository: catalogRepo,
|
||||||
apiClient: apiClient,
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
locationId: locationId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +126,13 @@ class AppServices extends KcAppServices {
|
||||||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||||
: SquareApiClient.sandbox(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(
|
return AppServices(
|
||||||
inventoryRepository: SquareInventoryRepository(
|
inventoryRepository: SquareInventoryRepository(
|
||||||
apiClient: squareApiClient,
|
apiClient: squareApiClient,
|
||||||
|
|
@ -122,7 +140,8 @@ class AppServices extends KcAppServices {
|
||||||
),
|
),
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient),
|
productPublishingRepository: wcPublishingRepo,
|
||||||
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import '../dashboard/application/get_dashboard_summary.dart';
|
||||||
import '../navigation/app_navigation.dart';
|
import '../navigation/app_navigation.dart';
|
||||||
import '../pages/dashboard_page.dart';
|
import '../pages/dashboard_page.dart';
|
||||||
import '../pages/finance_placeholder_page.dart';
|
import '../pages/finance_placeholder_page.dart';
|
||||||
import '../pages/integrations_placeholder_page.dart';
|
import '../pages/integrations_page.dart';
|
||||||
import '../shell/app_shell.dart';
|
import '../shell/app_shell.dart';
|
||||||
|
|
||||||
abstract final class AppRoutes {
|
abstract final class AppRoutes {
|
||||||
|
|
@ -117,10 +117,12 @@ abstract final class AppRoutes {
|
||||||
case integrations:
|
case integrations:
|
||||||
return _buildRoute(
|
return _buildRoute(
|
||||||
settings,
|
settings,
|
||||||
(context) => const AppShell(
|
(context) => AppShell(
|
||||||
selectedRoute: integrations,
|
selectedRoute: integrations,
|
||||||
title: 'Integrations',
|
title: 'Integrations',
|
||||||
child: IntegrationsPlaceholderPage(),
|
child: IntegrationsPage(
|
||||||
|
squareSyncController: AppScope.of(context).squareSyncController,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ void main() {
|
||||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
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', () {
|
test('defaults to sandbox environment', () {
|
||||||
// Constructing with no environment param should not throw.
|
// Constructing with no environment param should not throw.
|
||||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||||
|
|
@ -136,6 +143,7 @@ void main() {
|
||||||
final services = factory.createFake();
|
final services = factory.createFake();
|
||||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||||
|
expect(services.squareSyncController, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createWordPress returns wordpress services', () {
|
test('createWordPress returns wordpress services', () {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ export 'src/application/update_product_price.dart';
|
||||||
export 'src/application/update_product_status.dart';
|
export 'src/application/update_product_status.dart';
|
||||||
|
|
||||||
// Presentation
|
// Presentation
|
||||||
|
export 'src/presentation/widgets/square_sync_status_panel.dart';
|
||||||
export 'src/presentation/category_management_page.dart';
|
export 'src/presentation/category_management_page.dart';
|
||||||
export 'src/presentation/product_publishing_page.dart';
|
export 'src/presentation/product_publishing_page.dart';
|
||||||
export 'src/presentation/widgets/create_product_dialog.dart';
|
export 'src/presentation/widgets/create_product_dialog.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<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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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