Skip to content

Commit 64d5874

Browse files
authored
[Woo POS][Local Catalog] Add incremental sync triggers (#16264)
2 parents e4b16c1 + 1af088b commit 64d5874

25 files changed

+577
-215
lines changed

Modules/Sources/PointOfSale/Controllers/PointOfSaleObservableItemsController.swift

Lines changed: 136 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import protocol Yosemite.POSObservableDataSourceProtocol
66
import struct Yosemite.POSVariableParentProduct
77
import class Yosemite.GRDBObservableDataSource
88
import protocol Storage.GRDBManagerProtocol
9+
import protocol Yosemite.POSCatalogSyncCoordinatorProtocol
10+
import enum Yosemite.POSCatalogSyncError
911

1012
/// Controller that wraps an observable data source for POS items
1113
/// Uses computed state based on data source observations for automatic UI updates
1214
@Observable
1315
final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProtocol {
1416
private let dataSource: POSObservableDataSourceProtocol
17+
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
18+
private let siteID: Int64
1519

16-
// Track which items have been loaded at least once
17-
private var hasLoadedProducts = false
18-
private var hasLoadedVariationsForCurrentParent = false
20+
// Track loading and refresh for products and variations
21+
private var loadingState: LoadingState = LoadingState()
22+
private var refreshState: RefreshState = .idle
1923

2024
// Track current parent for variation state mapping
2125
private var currentParentItem: POSItem?
@@ -32,51 +36,71 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt
3236

3337
init(siteID: Int64,
3438
grdbManager: GRDBManagerProtocol,
35-
currencySettings: CurrencySettings) {
39+
currencySettings: CurrencySettings,
40+
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol) {
41+
self.siteID = siteID
3642
self.dataSource = GRDBObservableDataSource(
3743
siteID: siteID,
3844
grdbManager: grdbManager,
3945
currencySettings: currencySettings
4046
)
47+
self.catalogSyncCoordinator = catalogSyncCoordinator
4148
}
4249

4350
// periphery:ignore - used by tests
44-
init(dataSource: POSObservableDataSourceProtocol) {
51+
init(siteID: Int64,
52+
dataSource: POSObservableDataSourceProtocol,
53+
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol) {
54+
self.siteID = siteID
4555
self.dataSource = dataSource
56+
self.catalogSyncCoordinator = catalogSyncCoordinator
4657
}
4758

4859
func loadItems(base: ItemListBaseItem) async {
4960
switch base {
5061
case .root:
62+
if shouldRefresh(for: base) {
63+
await refreshItems(base: base)
64+
}
5165
dataSource.loadProducts()
52-
hasLoadedProducts = true
66+
loadingState.productsLoaded = true
67+
5368
case .parent(let parent):
5469
guard case .variableParentProduct(let parentProduct) = parent else {
5570
assertionFailure("Unsupported parent type for loading items: \(parent)")
5671
return
5772
}
5873

59-
// If switching to a different parent, reset the loaded flag
6074
if currentParentItem != parent {
6175
currentParentItem = parent
62-
hasLoadedVariationsForCurrentParent = false
76+
loadingState.variationsLoaded = false
6377
}
6478

79+
if shouldRefresh(for: base) {
80+
await refreshItems(base: base)
81+
}
6582
dataSource.loadVariations(for: parentProduct)
66-
hasLoadedVariationsForCurrentParent = true
83+
loadingState.variationsLoaded = true
6784
}
6885
}
6986

7087
func refreshItems(base: ItemListBaseItem) async {
71-
switch base {
72-
case .root:
73-
dataSource.refresh()
74-
case .parent(let parent):
75-
guard case .variableParentProduct(let parentProduct) = parent else {
76-
assertionFailure("Unsupported parent type for refreshing items: \(parent)")
77-
return
88+
if itemsEmpty(for: base) {
89+
refreshState = .loading
90+
}
91+
92+
do {
93+
try await catalogSyncCoordinator.performIncrementalSync(for: siteID)
94+
refreshState = .idle
95+
} catch let error as POSCatalogSyncError {
96+
switch error {
97+
case .syncAlreadyInProgress, .requestCancelled:
98+
refreshState = .idle
99+
default:
100+
refreshState = .error(error)
78101
}
79-
dataSource.loadVariations(for: parentProduct)
102+
} catch {
103+
refreshState = .error(error)
80104
}
81105
}
82106

@@ -94,67 +118,129 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt
94118
private extension PointOfSaleObservableItemsController {
95119
var containerState: ItemsContainerState {
96120
// Use .loading during initial load, .content otherwise
97-
if !hasLoadedProducts && dataSource.isLoadingProducts {
121+
if !loadingState.productsLoaded && dataSource.isLoadingProducts {
98122
return .loading
99123
}
100124
return .content
101125
}
102126

103127
var rootState: ItemListState {
104-
let items = dataSource.productItems
128+
computeItemListState(
129+
items: dataSource.productItems,
130+
hasLoaded: loadingState.productsLoaded,
131+
isLoading: dataSource.isLoadingProducts,
132+
error: dataSource.productError,
133+
hasMoreItems: dataSource.hasMoreProducts,
134+
errorType: PointOfSaleErrorState.errorOnLoadingProducts
135+
)
136+
}
105137

106-
// Initial state - not yet loaded
107-
if !hasLoadedProducts {
108-
return .initial
138+
var variationStates: [POSItem: ItemListState] {
139+
guard let parentItem = currentParentItem else {
140+
return [:]
109141
}
110142

111-
// Loading state - preserve existing items
112-
if dataSource.isLoadingProducts {
113-
return .loading(items)
114-
}
143+
let state = computeItemListState(
144+
items: dataSource.variationItems,
145+
hasLoaded: loadingState.variationsLoaded,
146+
isLoading: dataSource.isLoadingVariations,
147+
error: dataSource.variationError,
148+
hasMoreItems: dataSource.hasMoreVariations,
149+
errorType: PointOfSaleErrorState.errorOnLoadingVariations
150+
)
151+
return [parentItem: state]
152+
}
153+
}
115154

116-
// Error state
117-
if let error = dataSource.productError, items.isEmpty {
118-
return .error(.errorOnLoadingProducts(error: error))
155+
private extension PointOfSaleObservableItemsController {
156+
/// Determines if a refresh should be triggered
157+
func shouldRefresh(for type: ItemListBaseItem) -> Bool {
158+
if case .error = refreshState {
159+
return true
119160
}
161+
return itemsEmpty(for: type)
162+
}
120163

121-
// Empty state
122-
if items.isEmpty {
123-
return .empty
164+
/// Checks if items are loaded but empty
165+
func itemsEmpty(for type: ItemListBaseItem) -> Bool {
166+
switch type {
167+
case .root:
168+
return loadingState.productsLoaded && dataSource.productItems.isEmpty
169+
case .parent:
170+
return loadingState.variationsLoaded && dataSource.variationItems.isEmpty
124171
}
125-
126-
// Loaded state
127-
return .loaded(items, hasMoreItems: dataSource.hasMoreProducts)
128172
}
129173

130-
var variationStates: [POSItem: ItemListState] {
131-
guard let parentItem = currentParentItem else {
132-
return [:]
174+
/// Computes the item list state based on current conditions
175+
func computeItemListState(
176+
items: [POSItem],
177+
hasLoaded: Bool,
178+
isLoading: Bool,
179+
error: Error?,
180+
hasMoreItems: Bool,
181+
errorType: (Error) -> PointOfSaleErrorState
182+
) -> ItemListState {
183+
// Initial state - not yet loaded
184+
if !hasLoaded {
185+
return .initial
133186
}
134187

135-
let items = dataSource.variationItems
188+
// Loading state - preserve existing items
189+
if isLoading {
190+
return .loading(items)
191+
}
136192

137-
// Initial state - not yet loaded
138-
if !hasLoadedVariationsForCurrentParent {
139-
return [parentItem: .initial]
193+
// Refresh loading with empty items
194+
if refreshState == .loading && items.isEmpty {
195+
return .loading([])
140196
}
141197

142-
// Loading state - preserve existing items
143-
if dataSource.isLoadingVariations {
144-
return [parentItem: .loading(items)]
198+
// Error state for refresh
199+
if case .error(let refreshError) = refreshState {
200+
return items.isEmpty
201+
? .error(errorType(refreshError))
202+
: .inlineError(items, error: errorType(refreshError), context: .refresh)
145203
}
146204

147-
// Error state
148-
if let error = dataSource.variationError, items.isEmpty {
149-
return [parentItem: .error(.errorOnLoadingVariations(error: error))]
205+
// Error state for data source observation
206+
if let error = error, items.isEmpty {
207+
return .error(errorType(error))
150208
}
151209

152210
// Empty state
153211
if items.isEmpty {
154-
return [parentItem: .empty]
212+
return .empty
155213
}
156214

157215
// Loaded state
158-
return [parentItem: .loaded(items, hasMoreItems: dataSource.hasMoreVariations)]
216+
return .loaded(items, hasMoreItems: hasMoreItems)
217+
}
218+
219+
/// Represents the state of a refresh operation
220+
enum RefreshState: Equatable {
221+
case idle
222+
case loading
223+
case error(Error)
224+
225+
static func == (lhs: RefreshState, rhs: RefreshState) -> Bool {
226+
switch (lhs, rhs) {
227+
case (.idle, .idle):
228+
return true
229+
case (.loading, .loading):
230+
return true
231+
case (.error(let lhsError as POSCatalogSyncError), .error(let rhsError as POSCatalogSyncError)):
232+
return lhsError == rhsError
233+
case (.error(let lhsError), .error(let rhsError)):
234+
return lhsError.localizedDescription == rhsError.localizedDescription
235+
default:
236+
return false
237+
}
238+
}
239+
}
240+
241+
/// Encapsulates loading state for products and variations
242+
struct LoadingState {
243+
var productsLoaded = false
244+
var variationsLoaded = false
159245
}
160246
}

Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import protocol Yosemite.POSSearchHistoryProviding
1414
import enum Yosemite.POSItemType
1515
import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol
1616
import enum Yosemite.PointOfSaleBarcodeScanError
17+
import protocol Yosemite.POSCatalogSyncCoordinatorProtocol
1718

1819
protocol PointOfSaleAggregateModelProtocol {
1920
var cart: Cart { get }
@@ -51,6 +52,8 @@ protocol PointOfSaleAggregateModelProtocol {
5152
private let collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking
5253
let searchHistoryService: POSSearchHistoryProviding
5354
private let barcodeScanService: PointOfSaleBarcodeScanServiceProtocol
55+
private let siteID: Int64
56+
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?
5457

5558
private var startPaymentOnCardReaderConnection: AnyCancellable?
5659
private var cardReaderDisconnection: AnyCancellable?
@@ -86,7 +89,9 @@ protocol PointOfSaleAggregateModelProtocol {
8689
popularPurchasableItemsController: PointOfSaleItemsControllerProtocol,
8790
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol,
8891
soundPlayer: PointOfSaleSoundPlayerProtocol = PointOfSaleSoundPlayer(),
89-
paymentState: PointOfSalePaymentState = .idle) {
92+
paymentState: PointOfSalePaymentState = .idle,
93+
siteID: Int64,
94+
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil) {
9095
self.entryPointController = entryPointController
9196
self.purchasableItemsController = itemsController
9297
self.purchasableItemsSearchController = purchasableItemsSearchController
@@ -102,10 +107,14 @@ protocol PointOfSaleAggregateModelProtocol {
102107
self.popularPurchasableItemsController = popularPurchasableItemsController
103108
self.barcodeScanService = barcodeScanService
104109
self.soundPlayer = soundPlayer
110+
self.siteID = siteID
111+
self.catalogSyncCoordinator = catalogSyncCoordinator
105112

106113
publishCardReaderConnectionStatus()
107114
publishPaymentMessages()
108115
setupReaderReconnectionObservation()
116+
setupPaymentSuccessObservation()
117+
performIncrementalSync()
109118
}
110119
}
111120

@@ -592,6 +601,29 @@ extension PointOfSaleAggregateModel {
592601
}
593602
}
594603

604+
// MARK: - Incremental catalog sync on payment success
605+
606+
private extension PointOfSaleAggregateModel {
607+
@Sendable private func setupPaymentSuccessObservation() {
608+
withObservationTracking { [weak self] in
609+
guard let self else { return }
610+
if paymentState.isSuccess {
611+
performIncrementalSync()
612+
}
613+
} onChange: { [weak self] in
614+
guard let self else { return }
615+
DispatchQueue.main.async(execute: setupPaymentSuccessObservation)
616+
}
617+
}
618+
619+
private func performIncrementalSync() {
620+
guard let catalogSyncCoordinator else { return }
621+
Task {
622+
try? await catalogSyncCoordinator.performIncrementalSync(for: siteID)
623+
}
624+
}
625+
}
626+
595627
#if DEBUG
596628
extension PointOfSaleAggregateModel {
597629
func setPreviewState(paymentState: PointOfSalePaymentState, inlineMessage: PointOfSaleCardPresentPaymentMessageType?) {

Modules/Sources/PointOfSale/Models/PointOfSalePaymentState.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ struct PointOfSalePaymentState: Equatable {
2828
return card.shownFullScreen
2929
}
3030
}
31+
32+
var isSuccess: Bool {
33+
switch (card, cash) {
34+
case (.cardPaymentSuccessful, _):
35+
return true
36+
case (_, .paymentSuccess):
37+
return true
38+
default:
39+
return false
40+
}
41+
}
3142
}
3243

3344
enum PointOfSaleCardPaymentState: Equatable {

Modules/Sources/PointOfSale/Presentation/Item Selector/ItemList.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,7 @@ struct ItemList<HeaderView: View>: View {
118118
case .inlineError(_, let errorState, .pagination):
119119
POSListInlineErrorView(errorState: errorState,
120120
buttonAction: {
121-
Task { @MainActor in
122-
await itemsController.loadNextItems(base: node)
123-
}
121+
await itemsController.loadNextItems(base: node)
124122
})
125123
case .initial, .loaded, .error, .empty, .none, .inlineError(_, _, .refresh):
126124
EmptyView()
@@ -132,9 +130,7 @@ struct ItemList<HeaderView: View>: View {
132130
case .inlineError(_, let errorState, .refresh):
133131
POSListInlineErrorView(errorState: errorState,
134132
buttonAction: {
135-
Task { @MainActor in
136-
await itemsController.loadItems(base: .root)
137-
}
133+
await itemsController.loadItems(base: .root)
138134
})
139135
case .initial, .loaded, .error, .empty, .none, .loading, .inlineError(_, _, .pagination):
140136
EmptyView()

0 commit comments

Comments
 (0)