Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
052a5e2
Skip incremental sync if one hour hasn't passed in performSmartSync
staskus Oct 20, 2025
a2d89cb
Perform incremental POS catalog sync on pull to refresh
staskus Oct 20, 2025
2e2560a
Throw errors from missing paths in POSCatalogSyncCoordinator
staskus Oct 20, 2025
2bb944d
Remove unnecessary tests from GRDBObservableDataSourceTests
staskus Oct 20, 2025
3cfabc2
Add error handling to PointOfSaleObservableItemsController
staskus Oct 20, 2025
5d88b5a
Add a helper for continuously tracking observations
staskus Oct 21, 2025
557db95
Perform incremental sync after a successful payment on POS
staskus Oct 21, 2025
8d6c664
Verify that incremental sync is triggered on successful payment
staskus Oct 21, 2025
2e810f4
Perform incremental sync when POS launches
staskus Oct 21, 2025
81af5ec
Ensure ChildItemList refresh task is not canceled even if the view is…
staskus Oct 21, 2025
a49da86
Add a RefreshState to handle loading and item list view cases
staskus Oct 21, 2025
97b2109
Make withObservationTracking internal
staskus Oct 21, 2025
964662a
Merge branch 'trunk' into woomob-1098-woo-poslocal-catalog-add-increm…
staskus Oct 21, 2025
d7585bb
Merge branch 'trunk' into woomob-1098-woo-poslocal-catalog-add-increm…
staskus Oct 23, 2025
381fb22
Add a loading state for an outlined button style
staskus Oct 23, 2025
bb7d5f6
Set loading state to POSListInlineErrorView
staskus Oct 23, 2025
ce0cf16
Only refresh items list if there are no products
staskus Oct 23, 2025
b182b62
Ignore requestCancelled failures
staskus Oct 23, 2025
a31e679
Remove unused code
staskus Oct 24, 2025
83944b5
Remove unnecessary "Observation+WithObservationTrackingOf" file
staskus Oct 24, 2025
c17e0bb
Compare two POSCatalogSyncError in PointOfSaleObservableItemsController
staskus Oct 24, 2025
b9d209a
Remove a task wrapper around refreshable to avoid cancelation
staskus Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@ import protocol Yosemite.POSObservableDataSourceProtocol
import struct Yosemite.POSVariableParentProduct
import class Yosemite.GRDBObservableDataSource
import protocol Storage.GRDBManagerProtocol
import protocol Yosemite.POSCatalogSyncCoordinatorProtocol
import enum Yosemite.POSCatalogSyncError

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

// Track which items have been loaded at least once
private var hasLoadedProducts = false
private var hasLoadedVariationsForCurrentParent = false

// Track current parent for variation state mapping
private var currentParentItem: POSItem?
private var refreshState: RefreshState = .idle
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this being shared between root and variations? It feels like an opportunity to leak state between views by accident...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I made it shared since the incremental sync itself is shared. There's no separate sync for specific parent views. How do you think I should structure it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, I see. Makes sense.

The one niggle I have is that this can be out of sync with the view state, because Apple's code's in control of which PTR indicator is shown. If you PTR on a variation screen, then go back to a parent screen and scroll down to load more items, we'd be loading and refreshing at the same time. Which is kind of OK... but if we use the refresh state to decide to show a loading indicator, we could finish one load without finishing the incremental sync, and still show a loading indicator for the refresh.

If we remove the loading cell for refresh tasks it's less of an issue... and we deliberately don't show that for PTR in the existing implementation, as the spinner is enough.


var itemsViewState: ItemsViewState {
ItemsViewState(
Expand All @@ -32,22 +37,41 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt

init(siteID: Int64,
grdbManager: GRDBManagerProtocol,
currencySettings: CurrencySettings) {
currencySettings: CurrencySettings,
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol) {
self.siteID = siteID
self.dataSource = GRDBObservableDataSource(
siteID: siteID,
grdbManager: grdbManager,
currencySettings: currencySettings
)
self.catalogSyncCoordinator = catalogSyncCoordinator
}

// periphery:ignore - used by tests
init(dataSource: POSObservableDataSourceProtocol) {
init(siteID: Int64,
dataSource: POSObservableDataSourceProtocol,
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol) {
self.siteID = siteID
self.dataSource = dataSource
self.catalogSyncCoordinator = catalogSyncCoordinator
}

func loadItems(base: ItemListBaseItem) async {
switch base {
case .root:
// Refresh if there's an error or if items are empty after initial load
let shouldRefresh = {
if case .error = refreshState {
return true
}
return hasLoadedProducts && dataSource.productItems.isEmpty
}()

if shouldRefresh {
await refreshItems(base: base)
}

dataSource.loadProducts()
hasLoadedProducts = true
case .parent(let parent):
Expand All @@ -62,21 +86,38 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt
hasLoadedVariationsForCurrentParent = false
}

// Refresh if there's an error or if variations are empty after initial load
let shouldRefresh = {
if case .error = refreshState {
return true
}
return hasLoadedVariationsForCurrentParent && dataSource.variationItems.isEmpty
}()

if shouldRefresh {
await refreshItems(base: base)
}

dataSource.loadVariations(for: parentProduct)
hasLoadedVariationsForCurrentParent = true
}
}

func refreshItems(base: ItemListBaseItem) async {
switch base {
case .root:
dataSource.refresh()
case .parent(let parent):
guard case .variableParentProduct(let parentProduct) = parent else {
assertionFailure("Unsupported parent type for refreshing items: \(parent)")
return
refreshState = .loading

do {
try await catalogSyncCoordinator.performIncrementalSync(for: siteID)
refreshState = .idle
} catch let error as POSCatalogSyncError {
switch error {
case .syncAlreadyInProgress:
refreshState = .idle
default:
refreshState = .error(error)
}
dataSource.loadVariations(for: parentProduct)
} catch {
refreshState = .error(error)
}
}

Expand Down Expand Up @@ -108,12 +149,21 @@ private extension PointOfSaleObservableItemsController {
return .initial
}

// Loading state - preserve existing items
if dataSource.isLoadingProducts {
// Loading state - preserve existing items (both for data loading and refresh)
if dataSource.isLoadingProducts || refreshState == .loading {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Loading state - preserve existing items (both for data loading and refresh)
if dataSource.isLoadingProducts || refreshState == .loading {
// Loading state - preserve existing items (only use for data loading, refresh has a PTR spinner)
if dataSource.isLoadingProducts && refreshState != .loading {

We shouldn't show the loading cell if we're refreshing – the PTR indicator is sufficient, and changing the state breaks the refresh task anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented it to support the error "retry" case, but we could live without it. It's a shame there's no way to manually show a PTR loading animation. I remember trying to replicate it without success.

return .loading(items)
}

// Error state
// Error state for refresh
if case .error(let error) = refreshState {
if items.isEmpty {
return .error(.errorOnLoadingProducts(error: error))
} else {
return .inlineError(items, error: .errorOnLoadingProducts(error: error), context: .refresh)
}
}

// Error state for data source observation
if let error = dataSource.productError, items.isEmpty {
return .error(.errorOnLoadingProducts(error: error))
}
Expand All @@ -139,12 +189,21 @@ private extension PointOfSaleObservableItemsController {
return [parentItem: .initial]
}

// Loading state - preserve existing items
if dataSource.isLoadingVariations {
// Loading state - preserve existing items (both for data loading and refresh)
if dataSource.isLoadingVariations || refreshState == .loading {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Loading state - preserve existing items (both for data loading and refresh)
if dataSource.isLoadingVariations || refreshState == .loading {
// Loading state - preserve existing items (only use for data loading, refresh has a PTR spinner)
if dataSource.isLoadingProducts && refreshState != .loading {

return [parentItem: .loading(items)]
}

// Error state
// Error state for refresh
if case .error(let error) = refreshState {
if items.isEmpty {
return [parentItem: .error(.errorOnLoadingVariations(error: error))]
} else {
return [parentItem: .inlineError(items, error: .errorOnLoadingVariations(error: error), context: .refresh)]
}
}

// Error state for data source observation
if let error = dataSource.variationError, items.isEmpty {
return [parentItem: .error(.errorOnLoadingVariations(error: error))]
}
Expand All @@ -158,3 +217,25 @@ private extension PointOfSaleObservableItemsController {
return [parentItem: .loaded(items, hasMoreItems: dataSource.hasMoreVariations)]
}
}

private extension PointOfSaleObservableItemsController {
/// Represents the state of a refresh operation
enum RefreshState: Equatable {
case idle
case loading
case error(Error)

static func == (lhs: RefreshState, rhs: RefreshState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
return true
case (.loading, .loading):
return true
case (.error(let lhsError), .error(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation
import Observation

/// A helper method for continuously tracking observations on a value.
///
func withObservationTracking<T: Sendable>(of value: @Sendable @escaping @autoclosure () -> T, execute: @Sendable @escaping (T) -> Void) {
Observation.withObservationTracking {
execute(value())
} onChange: {
DispatchQueue.main.async {
withObservationTracking(of: value(), execute: execute)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import protocol Yosemite.POSSearchHistoryProviding
import enum Yosemite.POSItemType
import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol
import enum Yosemite.PointOfSaleBarcodeScanError
import protocol Yosemite.POSCatalogSyncCoordinatorProtocol

protocol PointOfSaleAggregateModelProtocol {
var cart: Cart { get }
Expand Down Expand Up @@ -51,6 +52,8 @@ protocol PointOfSaleAggregateModelProtocol {
private let collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking
let searchHistoryService: POSSearchHistoryProviding
private let barcodeScanService: PointOfSaleBarcodeScanServiceProtocol
private let siteID: Int64
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?

private var startPaymentOnCardReaderConnection: AnyCancellable?
private var cardReaderDisconnection: AnyCancellable?
Expand Down Expand Up @@ -86,7 +89,9 @@ protocol PointOfSaleAggregateModelProtocol {
popularPurchasableItemsController: PointOfSaleItemsControllerProtocol,
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol,
soundPlayer: PointOfSaleSoundPlayerProtocol = PointOfSaleSoundPlayer(),
paymentState: PointOfSalePaymentState = .idle) {
paymentState: PointOfSalePaymentState = .idle,
siteID: Int64,
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil) {
self.entryPointController = entryPointController
self.purchasableItemsController = itemsController
self.purchasableItemsSearchController = purchasableItemsSearchController
Expand All @@ -102,10 +107,14 @@ protocol PointOfSaleAggregateModelProtocol {
self.popularPurchasableItemsController = popularPurchasableItemsController
self.barcodeScanService = barcodeScanService
self.soundPlayer = soundPlayer
self.siteID = siteID
self.catalogSyncCoordinator = catalogSyncCoordinator

publishCardReaderConnectionStatus()
publishPaymentMessages()
setupReaderReconnectionObservation()
setupPaymentSuccessObservation()
performIncrementalSync()
}
}

Expand Down Expand Up @@ -592,6 +601,25 @@ extension PointOfSaleAggregateModel {
}
}

// MARK: - Incremental catalog sync on payment success

private extension PointOfSaleAggregateModel {
private func setupPaymentSuccessObservation() {
withObservationTracking(of: self.paymentState.isSuccess) { [weak self] success in
if success {
self?.performIncrementalSync()
}
}
}

private func performIncrementalSync() {
guard let catalogSyncCoordinator else { return }
Task {
try? await catalogSyncCoordinator.performIncrementalSync(for: siteID)
}
}
}

#if DEBUG
extension PointOfSaleAggregateModel {
func setPreviewState(paymentState: PointOfSalePaymentState, inlineMessage: PointOfSaleCardPresentPaymentMessageType?) {
Expand Down
11 changes: 11 additions & 0 deletions Modules/Sources/PointOfSale/Models/PointOfSalePaymentState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ struct PointOfSalePaymentState: Equatable {
return card.shownFullScreen
}
}

var isSuccess: Bool {
switch (card, cash) {
case (.cardPaymentSuccessful, _):
return true
case (_, .paymentSuccess):
return true
default:
return false
}
}
}

enum PointOfSaleCardPaymentState: Equatable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ private extension ChildItemList {
.transition(.opacity)
.refreshable {
analyticsTracker.trackRefresh()
await itemsController.refreshItems(base: node)
await Task {
await itemsController.refreshItems(base: node)
}.value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that this is only needed because we set the state to loading on a refresh, which isn't really needed – the refreshing spinner that iOS puts up for us is the indicator that something's happening. However, removing the task shows that some explicitlyCancelled errors are coming through in that case.

I remember writing some code to swallow these – they're not real errors, it's an AlamoFire thing when a previous request gets cancelled, I think. Perhaps we need the same in the incremental sync service somewhere? With that, and removing the call to always set a loading state, I think we could get rid of the task.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed it happening even before I had a loading state, and only had an error state. It was enough to set the error to nil before the incremental sync to cancel it.

In short, if I made any view updates while the refreshItems was still ongoing, the SwiftUI would kill the refreshable task, which caused an explicitlyCancelled error to happen. Even if we swallow the error not to be displayed, the refresh task remains canceled.

I think the problem may be with the view structure, since it doesn't happen on the ItemListView. However, I wasn't able to find a structure that wouldn't produce this cancellation...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you reproduce it with the existing item controller? If not, perhaps that rules out the view structure?

In short, if I made any view updates while the refreshItems was still ongoing, the SwiftUI would kill the refreshable task, which caused an explicitlyCancelled error to happen. Even if we swallow the error not to be displayed, the refresh task remains canceled.

Yes, that's unavoidable unfortunately. I'm confusing two memories, we had explicitly cancelled errors from elsewhere for something else. The key is that we can't change the view while we're refreshing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you reproduce it with the existing item controller? If not, perhaps that rules out the view structure?

I'll try!

}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public struct PointOfSaleEntryPointView: View {
private let barcodeScanService: PointOfSaleBarcodeScanServiceProtocol
private let siteTimezone: TimeZone
private let services: POSDependencyProviding
private let siteID: Int64
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?

/// periphery: ignore - public in preparation of move to POS module
public init(siteID: Int64,
Expand Down Expand Up @@ -70,11 +72,12 @@ public struct PointOfSaleEntryPointView: View {
// Note: We check feature flag here for eligibility. Once eligibility checking is
// refactored to be more centralized, this check can be simplified.
let isGRDBEnabled = services.featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1)
if let grdbManager = grdbManager, catalogSyncCoordinator != nil, isGRDBEnabled {
if let grdbManager = grdbManager, let catalogSyncCoordinator, isGRDBEnabled {
self.itemsController = PointOfSaleObservableItemsController(
siteID: siteID,
grdbManager: grdbManager,
currencySettings: services.currency.currencySettings
currencySettings: services.currency.currencySettings,
catalogSyncCoordinator: catalogSyncCoordinator
)
} else {
self.itemsController = PointOfSaleItemsController(
Expand Down Expand Up @@ -127,6 +130,8 @@ public struct PointOfSaleEntryPointView: View {
self.orderListModel = POSOrderListModel(ordersController: ordersController, receiptSender: receiptSender)
self.siteTimezone = siteTimezone
self.services = services
self.siteID = siteID
self.catalogSyncCoordinator = catalogSyncCoordinator
}

public var body: some View {
Expand Down Expand Up @@ -155,7 +160,9 @@ public struct PointOfSaleEntryPointView: View {
collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker,
searchHistoryService: searchHistoryService,
popularPurchasableItemsController: popularPurchasableItemsController,
barcodeScanService: barcodeScanService)
barcodeScanService: barcodeScanService,
siteID: siteID,
catalogSyncCoordinator: catalogSyncCoordinator)
}
.environment(\.posAnalytics, services.analytics)
.environment(\.posCurrencyProvider, services.currency)
Expand Down
10 changes: 7 additions & 3 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ struct POSPreviewHelpers {
searchHistoryService: POSSearchHistoryProviding = PointOfSalePreviewHistoryService(),
popularItemsController: PointOfSaleItemsControllerProtocol = PointOfSalePreviewItemsController(),
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol = PointOfSalePreviewBarcodeScanService(),
analytics: POSAnalyticsProviding = EmptyPOSAnalytics()
analytics: POSAnalyticsProviding = EmptyPOSAnalytics(),
siteID: Int64 = 1,
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil
) -> PointOfSaleAggregateModel {
return PointOfSaleAggregateModel(
entryPointController: POSEntryPointController(eligibilityChecker: PointOfSalePreviewTabEligibilityChecker()),
Expand All @@ -228,7 +230,9 @@ struct POSPreviewHelpers {
collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker,
searchHistoryService: searchHistoryService,
popularPurchasableItemsController: popularItemsController,
barcodeScanService: barcodeScanService
barcodeScanService: barcodeScanService,
siteID: siteID,
catalogSyncCoordinator: catalogSyncCoordinator
)
}

Expand Down Expand Up @@ -619,7 +623,7 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
try await Task.sleep(nanoseconds: 500_000_000)
}

func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws {
func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval, incrementalSyncMaxAge: TimeInterval) async throws {
// Simulates a smart sync operation with a 1 second delay.
try await Task.sleep(nanoseconds: 1_000_000_000)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,6 @@ public final class GRDBObservableDataSource: POSObservableDataSourceProtocol {
setupVariationObservation(parentProduct: parentProduct)
}

public func refresh() {
// No-op: database observation automatically updates when data changes during incremental sync
}

// MARK: - ValueObservation Setup

private func setupProductObservation() {
Expand Down
Loading