diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index 99dd74b46c2..62e842d7dea 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -40,7 +40,7 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController init(itemProvider: PointOfSaleItemServiceProtocol, itemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactoryProtocol, - initialState: ItemsViewState = ItemsViewState(containerState: .loading, + initialState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .initial, itemStates: [:])), analyticsProvider: POSAnalyticsProviding) { @@ -213,7 +213,7 @@ private extension PointOfSaleItemsController { func setRootLoadingState() { let items = itemsViewState.itemsStack.root.items - let isInitialState = itemsViewState.containerState == .loading && itemsViewState.itemsStack.root == .initial + let isInitialState = itemsViewState.containerState == .loading() && itemsViewState.itemsStack.root == .initial if isInitialState { // Transition from initial to loading on first load itemsViewState.itemsStack.root = .loading([]) diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleObservableItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleObservableItemsController.swift index 4584707341b..d96304d7f4b 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleObservableItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleObservableItemsController.swift @@ -45,6 +45,8 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt currencySettings: currencySettings ) self.catalogSyncCoordinator = catalogSyncCoordinator + + preloadloadLastFullSyncState() } // periphery:ignore - used by tests @@ -54,6 +56,8 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt self.siteID = siteID self.dataSource = dataSource self.catalogSyncCoordinator = catalogSyncCoordinator + + preloadloadLastFullSyncState() } func loadItems(base: ItemListBaseItem) async { @@ -117,13 +121,29 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt // MARK: - State Computation private extension PointOfSaleObservableItemsController { var containerState: ItemsContainerState { - // Use .loading during initial load, .content otherwise + if isInitialCatalogSync { + return .loading(isCatalogSyncing: true) + } + if !loadingState.productsLoaded && dataSource.isLoadingProducts { - return .loading + return .loading() } return .content } + var isInitialCatalogSync: Bool { + guard let syncState = catalogSyncCoordinator.fullSyncStateModel.state[siteID] else { + return false + } + + switch syncState { + case .syncStarted(_, true), .syncNeverDone: + return true + default: + return false + } + } + var rootState: ItemListState { computeItemListState( items: dataSource.productItems, @@ -244,3 +264,12 @@ private extension PointOfSaleObservableItemsController { var variationsLoaded = false } } + +private extension PointOfSaleObservableItemsController { + func preloadloadLastFullSyncState() { + Task { @MainActor in + /// Ensure last full sync state is loaded with initial value + _ = await catalogSyncCoordinator.loadLastFullSyncState(for: siteID) + } + } +} diff --git a/Modules/Sources/PointOfSale/Models/ItemsContainerState.swift b/Modules/Sources/PointOfSale/Models/ItemsContainerState.swift index 69e1e99705d..e22a8a89026 100644 --- a/Modules/Sources/PointOfSale/Models/ItemsContainerState.swift +++ b/Modules/Sources/PointOfSale/Models/ItemsContainerState.swift @@ -1,9 +1,18 @@ import Foundation enum ItemsContainerState { - case loading + case loading(isCatalogSyncing: Bool = false) case error(PointOfSaleErrorState) case content + + var isCatalogSyncing: Bool { + switch self { + case .loading(let isCatalogSyncing): + return isCatalogSyncing + default: + return false + } + } } extension ItemsContainerState: Equatable {} diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index af6781a0bc4..f8b5373fb12 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -15,6 +15,7 @@ import enum Yosemite.POSItemType import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol import enum Yosemite.PointOfSaleBarcodeScanError import protocol Yosemite.POSCatalogSyncCoordinatorProtocol +import class Yosemite.POSCatalogSyncCoordinator protocol PointOfSaleAggregateModelProtocol { var cart: Cart { get } diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift index 97059e5d01d..6cf7e343a96 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift @@ -1,6 +1,14 @@ import SwiftUI struct PointOfSaleLoadingView: View { + private let isCatalogSyncing: Bool + private let onExit: (() -> Void)? + + init(isCatalogSyncing: Bool = false, onExit: (() -> Void)? = nil) { + self.isCatalogSyncing = isCatalogSyncing + self.onExit = onExit + } + var body: some View { HStack(alignment: .center) { Spacer() @@ -8,7 +16,29 @@ struct PointOfSaleLoadingView: View { Spacer() ProgressView() .progressViewStyle(POSProgressViewStyle()) - Spacer() + + if isCatalogSyncing { + Spacer().frame(height: POSSpacing.large * 2) + Text(Localization.syncingTitle) + .font(.posHeadingBold) + Spacer() + VStack(spacing: POSSpacing.medium) { + Button { + onExit?() + } label: { + Text(Localization.exitButtonTitle) + .font(.posBodySmallBold(underline: true)) + .foregroundStyle(Color.posOnSurface) + } + + Text(Localization.exitButtonDescription) + .font(.posCaptionRegular) + .foregroundStyle(Color.posOnSurfaceVariantLowest) + } + .padding(.bottom, POSPadding.large) + } else { + Spacer() + } } .multilineTextAlignment(.center) Spacer() @@ -20,3 +50,29 @@ struct PointOfSaleLoadingView: View { #Preview { PointOfSaleLoadingView() } + +#Preview("Catalog Syncing") { + PointOfSaleLoadingView(isCatalogSyncing: true) {} +} + +private extension PointOfSaleLoadingView { + struct Localization { + static let syncingTitle = NSLocalizedString( + "pointOfSale.catalogLoadingView.title", + value: "Syncing catalog", + comment: "A title of a full screen view that is displayed while the POS catalog is being synced." + ) + + static let exitButtonTitle = NSLocalizedString( + "pointOfSale.catalogLoadingView.exitButton.title", + value: "Exit POS", + comment: "A button that exits POS." + ) + + static let exitButtonDescription = NSLocalizedString( + "pointOfSale.catalogLoadingView.exitButton.description", + value: "Syncing will continue in the background.", + comment: "A description within a full screen loading view for POS catalog." + ) + } +} diff --git a/Modules/Sources/PointOfSale/Presentation/CouponRowView.swift b/Modules/Sources/PointOfSale/Presentation/CouponRowView.swift index 19cb233c69d..28ff4ce62e1 100644 --- a/Modules/Sources/PointOfSale/Presentation/CouponRowView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CouponRowView.swift @@ -92,7 +92,7 @@ private extension CouponRowView { static let horizontalPadding: CGFloat = POSPadding.medium static let horizontalElementSpacing: CGFloat = POSSpacing.medium static let cardContentHorizontalPadding: CGFloat = POSPadding.medium - static let titleFont: POSFontStyle = .posBodySmallBold + static let titleFont: POSFontStyle = .posBodySmallBold() static let titleSummarySpacing: CGFloat = POSSpacing.xSmall static let summaryFont: POSFontStyle = .posBodySmallRegular() } diff --git a/Modules/Sources/PointOfSale/Presentation/ItemRowView.swift b/Modules/Sources/PointOfSale/Presentation/ItemRowView.swift index f23a4555280..ce14517933d 100644 --- a/Modules/Sources/PointOfSale/Presentation/ItemRowView.swift +++ b/Modules/Sources/PointOfSale/Presentation/ItemRowView.swift @@ -131,7 +131,7 @@ private extension ItemRowView { static let horizontalElementSpacing: CGFloat = POSSpacing.medium static let cardContentHorizontalPadding: CGFloat = POSPadding.medium static let itemTitleAndPriceSpacing: CGFloat = POSSpacing.xSmall - static let itemTitleFont: POSFontStyle = .posBodySmallBold + static let itemTitleFont: POSFontStyle = .posBodySmallBold() static let itemSubtitleFont: POSFontStyle = .posBodySmallRegular() static let itemPriceFont: POSFontStyle = .posBodySmallRegular() static let titleSubtitleLineLimit: Int = 4 diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 49a73f5ce23..8d2f488db1f 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -234,7 +234,7 @@ private struct POSOrderRowView: View { private var orderHeaderRow: some View { HStack(alignment: .center) { Text(POSOrderListView.Localization.orderTitle(order.number)) - .font(.posBodySmallBold) + .font(.posBodySmallBold()) .foregroundStyle(Color.posOnSurface) .fixedSize(horizontal: false, vertical: true) diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift index 949f5c81dd6..3f894224b77 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift @@ -6,6 +6,7 @@ struct PointOfSaleDashboardView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.posAnalytics) private var analytics @Environment(\.posExternalViews) private var externalViews + @Environment(\.dismiss) private var dismiss @State private var showExitPOSModal: Bool = false @State private var showSupport: Bool = false @@ -39,7 +40,7 @@ struct PointOfSaleDashboardView: View { // MARK: View State enum ViewState: Equatable { - case loading + case loading(isCatalogSyncing: Bool = false) case ineligible(reason: POSIneligibleReason) case error(PointOfSaleErrorState) case content @@ -58,8 +59,11 @@ struct PointOfSaleDashboardView: View { @Bindable var posModel = posModel ZStack(alignment: .bottomLeading) { switch viewState { - case .loading: - PointOfSaleLoadingView() + case .loading(let isCatalogSyncing): + PointOfSaleLoadingView( + isCatalogSyncing: isCatalogSyncing, + onExit: { dismiss() } + ) .transition(.opacity) .ignoresSafeArea() case .ineligible(let reason): @@ -107,7 +111,7 @@ struct PointOfSaleDashboardView: View { CGSizeMake(floatingSize.width + Constants.floatingControlHorizontalOffset, floatingSize.height + Constants.floatingControlVerticalOffset)) .environment(\.posBackgroundAppearance, backgroundAppearance) - .animation(.easeInOut, value: viewState == .loading) + .animation(.easeInOut, value: viewState == .loading()) .background(Color.posSurface) .navigationBarBackButtonHidden(true) .posModal(item: $posModel.cardPresentPaymentOnboardingViewContainer, onDismiss: { diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSConnectivityView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSConnectivityView.swift index 6ddf49eb95d..123c2d52798 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSConnectivityView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSConnectivityView.swift @@ -30,11 +30,11 @@ struct POSConnectivityView: View { HStack(spacing: Constants.spacing) { Image(systemName: "wifi.exclamationmark") .foregroundColor(Color.posOnSecondaryContainer) - .font(.posBodySmallBold) + .font(.posBodySmallBold()) Text(Localization.title) .foregroundColor(Color.posOnSecondaryContainer) - .font(.posBodySmallBold) + .font(.posBodySmallBold()) } .padding(.vertical, Constants.verticalPadding) .padding(.horizontal, Constants.horizontalPadding) diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift index 9452f4ea65a..7906cb8627e 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift @@ -110,7 +110,7 @@ private enum Localization { VStack(alignment: .leading, spacing: Constants.textSpacing) { Text("This is a subtitle that explains more about the notice.") Text("Here's a hint about what to do next. Learn More") - .font(.posBodySmallBold) + .font(.posBodySmallBold()) .foregroundColor(Color.posPrimary) } } @@ -126,7 +126,7 @@ private enum Localization { VStack(alignment: .leading, spacing: Constants.textSpacing) { Text("This is a subtitle that explains more about the notice.") Text("Here's a hint about what to do next. Learn More") - .font(.posBodySmallBold) + .font(.posBodySmallBold()) .foregroundColor(Color.posPrimary) } } diff --git a/Modules/Sources/PointOfSale/Utils/POSFontStyle.swift b/Modules/Sources/PointOfSale/Utils/POSFontStyle.swift index ed78d85b5d6..3fb8628e12f 100644 --- a/Modules/Sources/PointOfSale/Utils/POSFontStyle.swift +++ b/Modules/Sources/PointOfSale/Utils/POSFontStyle.swift @@ -11,7 +11,7 @@ enum POSFontStyle { case posBodyLargeRegular(underline: Bool = false) case posBodyMediumBold case posBodyMediumRegular(underline: Bool = false) - case posBodySmallBold + case posBodySmallBold(underline: Bool = false) case posBodySmallRegular(underline: Bool = false) case posCaptionBold case posCaptionRegular @@ -105,7 +105,8 @@ private struct POSScaledFont: ViewModifier { switch style { case .posBodyLargeRegular(let underline), .posBodyMediumRegular(let underline), - .posBodySmallRegular(let underline): + .posBodySmallRegular(let underline), + .posBodySmallBold(let underline): return underline default: return false @@ -167,7 +168,9 @@ extension UIContentSizeCategory { Text("Body Medium Regular Underline") .font(.posBodyMediumRegular(underline: true)) Text("Body Small Bold") - .font(.posBodySmallBold) + .font(.posBodySmallBold()) + Text("Body Small Bold Underline") + .font(.posBodySmallBold(underline: true)) Text("Body Small Regular") .font(.posBodySmallRegular()) Text("Body Small Regular Underline") diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 89b6e150cf7..50904721b16 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -29,6 +29,8 @@ import typealias Yosemite.OrderItemAttribute import class Yosemite.POSOrderListService import class Yosemite.POSOrderListFetchStrategyFactory import protocol Yosemite.POSCatalogSyncCoordinatorProtocol +import enum Yosemite.POSCatalogSyncState +import class Yosemite.POSCatalogSyncStateModel import protocol Yosemite.POSCatalogSettingsServiceProtocol import struct Yosemite.POSCatalogInfo import struct Yosemite.Site @@ -100,7 +102,7 @@ struct PointOfSalePreviewPurchasableItemFetchStrategy: PointOfSalePurchasableIte } final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerProtocol { - @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading, + @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) func enableCoupons() async { } @@ -112,7 +114,7 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro } final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControllerProtocol { - @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading, + @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) @@ -627,6 +629,12 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol // Simulates a smart sync operation with a 1 second delay. try await Task.sleep(nanoseconds: 1_000_000_000) } + + let fullSyncStateModel = POSCatalogSyncStateModel() + + func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { + return fullSyncStateModel.state[siteID] ?? .syncCompleted(siteID: siteID) + } } #endif diff --git a/Modules/Sources/PointOfSale/ViewHelpers/PointOfSaleDashboardViewHelper.swift b/Modules/Sources/PointOfSale/ViewHelpers/PointOfSaleDashboardViewHelper.swift index 4258f9c541c..bba807cb74b 100644 --- a/Modules/Sources/PointOfSale/ViewHelpers/PointOfSaleDashboardViewHelper.swift +++ b/Modules/Sources/PointOfSale/ViewHelpers/PointOfSaleDashboardViewHelper.swift @@ -7,19 +7,21 @@ struct PointOfSaleDashboardViewHelper { itemsContainerState: ItemsContainerState, horizontalSizeClass: UserInterfaceSizeClass? ) -> PointOfSaleDashboardView.ViewState { + guard case .regular = horizontalSizeClass else { return .unsupportedWidth } guard let eligibilityState else { - return .loading + return .loading(isCatalogSyncing: itemsContainerState.isCatalogSyncing) } switch eligibilityState { case .eligible: + // Check items container state switch itemsContainerState { - case .loading: - return .loading + case let .loading(isCatalogSyncing): + return .loading(isCatalogSyncing: isCatalogSyncing) case .error(let error): return .error(error) case .content: diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 65ff8f06bdd..8bfb5c45d69 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -27,6 +27,13 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// performs full sync; otherwise, performs incremental sync /// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval, incrementalSyncMaxAge: TimeInterval) async throws + + /// Stream that emits full sync state updates + var fullSyncStateModel: POSCatalogSyncStateModel { get } + + /// Returns the last known full sync state for a site + /// If no state is cached, determines state from lastSyncDate + func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState } public extension POSCatalogSyncCoordinatorProtocol { @@ -50,6 +57,7 @@ public enum POSCatalogSyncError: Error, Equatable { case syncAlreadyInProgress(siteID: Int64) case negativeMaxAge case requestCancelled + case shouldNotSync } public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { @@ -59,11 +67,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { private let catalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol private let siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol - /// Tracks ongoing full syncs by site ID to prevent duplicates - private var ongoingSyncs: Set = [] /// Tracks ongoing incremental syncs by site ID to prevent duplicates private var ongoingIncrementalSyncs: Set = [] + /// Observable model for full sync state updates + public nonisolated let fullSyncStateModel: POSCatalogSyncStateModel = .init() + public init(fullSyncService: POSCatalogFullSyncServiceProtocol, incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol, grdbManager: GRDBManagerProtocol, @@ -82,28 +91,28 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } guard try await shouldPerformFullSync(for: siteID, maxAge: maxAge) else { - return + throw POSCatalogSyncError.shouldNotSync } - if ongoingSyncs.contains(siteID) { + if case .syncStarted = fullSyncStateModel.state[siteID] { DDLogInfo("âš ī¸ POSCatalogSyncCoordinator: Sync already in progress for site \(siteID)") throw POSCatalogSyncError.syncAlreadyInProgress(siteID: siteID) } - // Mark sync as in progress - ongoingSyncs.insert(siteID) - - // Ensure cleanup happens regardless of success or failure - defer { - ongoingSyncs.remove(siteID) - } + await emitSyncState(.syncStarted(siteID: siteID, isInitialSync: lastFullSyncDate(for: siteID) == nil)) DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)") do { _ = try await fullSyncService.startFullSync(for: siteID) + emitSyncState(.syncCompleted(siteID: siteID)) } catch AFError.explicitlyCancelled, is CancellationError { + emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled)) throw POSCatalogSyncError.requestCancelled + } catch { + DDLogError("â›”ī¸ POSCatalogSyncCoordinator failed to complete sync for site \(siteID): \(error)") + emitSyncState(.syncFailed(siteID: siteID, error: error)) + throw error } DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)") @@ -266,8 +275,70 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { return true } } + + public func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { + if let cached = fullSyncStateModel.state[siteID] { + return cached + } + + let state: POSCatalogSyncState + + if await lastFullSyncDate(for: siteID) == nil { + state = .syncNeverDone(siteID: siteID) + } else { + state = .syncCompleted(siteID: siteID) + } + + fullSyncStateModel.state[siteID] = state + return state + } } +// MARK: - Syncing State + +private extension POSCatalogSyncCoordinator { + func emitSyncState(_ state: POSCatalogSyncState) { + let siteID: Int64 = switch state { + case .syncStarted(let id, _), .syncCompleted(let id), .syncFailed(let id, _), .syncNeverDone(let id): + id + } + + fullSyncStateModel.state[siteID] = state + } +} + +@Observable +public class POSCatalogSyncStateModel { + public var state: [Int64: POSCatalogSyncState] = [:] + + public init() {} +} + + +public enum POSCatalogSyncState: Equatable { + case syncStarted(siteID: Int64, isInitialSync: Bool) + case syncCompleted(siteID: Int64) + case syncFailed(siteID: Int64, error: Error) + case syncNeverDone(siteID: Int64) + + public static func == (lhs: POSCatalogSyncState, rhs: POSCatalogSyncState) -> Bool { + switch (lhs, rhs) { + case (.syncStarted(let lhsSiteID, let lhsInitial), .syncStarted(let rhsSiteID, let rhsInitial)): + return lhsSiteID == rhsSiteID && lhsInitial == rhsInitial + case (.syncCompleted(let lhsSiteID), .syncCompleted(let rhsSiteID)): + return lhsSiteID == rhsSiteID + case (.syncFailed(let lhsSiteID, let lhsError), .syncFailed(let rhsSiteID, let rhsError)): + return lhsSiteID == rhsSiteID && lhsError.localizedDescription == rhsError.localizedDescription + case (.syncNeverDone(let lhsSiteID), .syncNeverDone(let rhsSiteID)): + return lhsSiteID == rhsSiteID + default: + return false + } + } +} + +// MARK: - Constants + private extension POSCatalogSyncCoordinator { enum Constants { static let defaultSizeLimitForPOSCatalog = 1000 diff --git a/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleItemsControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleItemsControllerTests.swift index 92c37a83792..04981540fbf 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleItemsControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleItemsControllerTests.swift @@ -18,7 +18,7 @@ final class PointOfSaleItemsControllerTests { analyticsProvider: MockPOSAnalytics() ) - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) itemProvider.shouldSimulateTwoPages = true await sut.loadItems(base: .root) @@ -43,7 +43,7 @@ final class PointOfSaleItemsControllerTests { let expectedItems = MockPointOfSaleItemService.makeInitialItems() itemProvider.itemPages = [expectedItems] - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) // When await sut.loadItems(base: .root) @@ -64,7 +64,7 @@ final class PointOfSaleItemsControllerTests { ) let expectedItems = MockPointOfSaleItemService.makeInitialItems() - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) itemProvider.shouldSimulateTwoPages = true // When @@ -85,7 +85,7 @@ final class PointOfSaleItemsControllerTests { analyticsProvider: MockPOSAnalytics() ) - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) let expectedItems = MockPointOfSaleItemService.makeInitialItems() itemProvider.itemPages = [expectedItems] @@ -112,7 +112,7 @@ final class PointOfSaleItemsControllerTests { ) // When/Then - #expect(sut.itemsViewState.containerState == .loading) + #expect(sut.itemsViewState.containerState == .loading()) } @Test func loadNextItems_when_initial_items_empty_then_container_state_is_content_and_root_state_is_empty() async throws { @@ -126,7 +126,7 @@ final class PointOfSaleItemsControllerTests { itemProvider.shouldReturnZeroItems = true - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) // When await sut.loadNextItems(base: .root) @@ -148,7 +148,7 @@ final class PointOfSaleItemsControllerTests { let initialItems = MockPointOfSaleItemService.makeInitialItems() itemProvider.itemPages = [initialItems] - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) // When await sut.loadNextItems(base: .root) @@ -191,7 +191,7 @@ final class PointOfSaleItemsControllerTests { analyticsProvider: MockPOSAnalytics() ) - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) itemProvider.shouldSimulateTwoPages = true await sut.loadItems(base: .root) @@ -304,7 +304,7 @@ final class PointOfSaleItemsControllerTests { itemProvider.shouldReturnZeroItems = true - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) // When await sut.loadItems(base: .root) @@ -324,7 +324,7 @@ final class PointOfSaleItemsControllerTests { ) itemProvider.errorToThrow = MockError.requestFailed - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) // When await sut.loadItems(base: .root) @@ -343,7 +343,7 @@ final class PointOfSaleItemsControllerTests { analyticsProvider: MockPOSAnalytics() ) - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) itemProvider.shouldSimulateTwoPages = true await sut.loadItems(base: .root) @@ -398,7 +398,7 @@ final class PointOfSaleItemsControllerTests { analyticsProvider: MockPOSAnalytics() ) - try #require(sut.itemsViewState.containerState == .loading) + try #require(sut.itemsViewState.containerState == .loading()) let expectedItems = MockPointOfSaleItemService.makeInitialItems() itemProvider.itemPages = [expectedItems] diff --git a/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleObservableItemsControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleObservableItemsControllerTests.swift index 349be4e5232..9b9b2fe13d2 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleObservableItemsControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/PointOfSaleObservableItemsControllerTests.swift @@ -42,7 +42,11 @@ final class PointOfSaleObservableItemsControllerTests { let sut = PointOfSaleObservableItemsController(siteID: 123, dataSource: dataSource, catalogSyncCoordinator: coordinator) // Then - #expect(sut.itemsViewState.containerState == .loading) + guard case .loading(let isCatalogSyncing) = sut.itemsViewState.containerState else { + Issue.record("Expected loading state") + return + } + #expect(isCatalogSyncing == false) #expect(sut.itemsViewState.itemsStack.root == .initial) #expect(sut.itemsViewState.itemsStack.itemStates.isEmpty) } @@ -435,4 +439,25 @@ final class PointOfSaleObservableItemsControllerTests { #expect(sut.itemsViewState.itemsStack.itemStates[parentItem] == .loaded(mockVariations, hasMoreItems: false)) #expect(coordinator.performIncrementalSyncInvocationCount == 5) } + + @Test func test_container_state_includes_catalog_syncing_flag_when_initial_sync_in_progress() async { + // Given + let dataSource = MockPOSObservableDataSource() + let coordinator = MockPOSCatalogSyncCoordinator() + let siteID: Int64 = 123 + let sut = PointOfSaleObservableItemsController(siteID: siteID, dataSource: dataSource, catalogSyncCoordinator: coordinator) + + dataSource.isLoadingProducts = true + coordinator.fullSyncStateModel.state[siteID] = .syncStarted(siteID: siteID, isInitialSync: true) + + // When + let containerState = sut.itemsViewState.containerState + + // Then + guard case .loading(let isCatalogSyncing) = containerState else { + Issue.record("Expected loading state") + return + } + #expect(isCatalogSyncing == true) + } } diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index 20c833e0dd2..d0da6326193 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -62,4 +62,10 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { throw error } } + + let fullSyncStateModel = POSCatalogSyncStateModel() + + func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { + return fullSyncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID) + } } diff --git a/Modules/Tests/PointOfSaleTests/ViewHelpers/PointOfSaleDashboardViewHelperTests.swift b/Modules/Tests/PointOfSaleTests/ViewHelpers/PointOfSaleDashboardViewHelperTests.swift index 9cb932b6d4f..43182b941d7 100644 --- a/Modules/Tests/PointOfSaleTests/ViewHelpers/PointOfSaleDashboardViewHelperTests.swift +++ b/Modules/Tests/PointOfSaleTests/ViewHelpers/PointOfSaleDashboardViewHelperTests.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI import Testing import enum WooFoundationCore.CurrencyCode +import Yosemite @testable import PointOfSale struct PointOfSaleDashboardViewHelperTests { @@ -57,7 +58,7 @@ struct PointOfSaleDashboardViewHelperTests { ) // Then - #expect(result == .loading) + #expect(result == .loading()) } @Test(arguments: [ @@ -90,7 +91,7 @@ struct PointOfSaleDashboardViewHelperTests { @Test func determineViewState_when_eligible_and_loading_returns_loading() async throws { // Given let eligibilityState: POSEligibilityState = .eligible - let itemsContainerState: ItemsContainerState = .loading + let itemsContainerState: ItemsContainerState = .loading() let horizontalSizeClass: UserInterfaceSizeClass = .regular // When @@ -101,7 +102,7 @@ struct PointOfSaleDashboardViewHelperTests { ) // Then - #expect(result == .loading) + #expect(result == .loading()) } @Test func determineViewState_when_eligible_and_content_returns_content() async throws { @@ -184,7 +185,7 @@ struct PointOfSaleDashboardViewHelperTests { ) // Then - #expect(result == .loading) + #expect(result == .loading()) } @Test func determineViewState_ineligible_state_takes_priority_over_containerState() async throws { @@ -217,7 +218,7 @@ struct PointOfSaleDashboardViewHelperTests { } @Test(arguments: [ - (PointOfSaleDashboardView.ViewState.loading, false), + (PointOfSaleDashboardView.ViewState.loading(), false), (PointOfSaleDashboardView.ViewState.ineligible(reason: .featureSwitchDisabled), false) ]) func showsFloatingControl_when_loading_or_ineligible_returns_false(viewState: PointOfSaleDashboardView.ViewState, expected: Bool) async throws { diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index c267bf701fc..1b163f79d76 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -111,10 +111,11 @@ struct POSCatalogSyncCoordinatorTests { let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo) - // When - max age is 1 hour - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + // When - max age is 1 hour / Then + await #expect(throws: POSCatalogSyncError.shouldNotSync) { + try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + } - // Then #expect(mockSyncService.startFullSyncCallCount == 0) } @@ -128,11 +129,12 @@ struct POSCatalogSyncCoordinatorTests { try createSiteInDatabase(siteID: siteA, lastFullSyncDate: oneHourAgo) try createSiteInDatabase(siteID: siteB, lastFullSyncDate: nil) - // When - let _ = try await sut.performFullSyncIfApplicable(for: siteA, maxAge: 2 * sampleMaxAge) + // When / Then + await #expect(throws: POSCatalogSyncError.shouldNotSync) { + let _ = try await sut.performFullSyncIfApplicable(for: siteA, maxAge: 2 * sampleMaxAge) + } let _ = try await sut.performFullSyncIfApplicable(for: siteB, maxAge: 2 * sampleMaxAge) - // Then #expect(mockSyncService.startFullSyncCallCount == 1) #expect(mockSyncService.lastSyncSiteID == siteB) } @@ -168,9 +170,11 @@ struct POSCatalogSyncCoordinatorTests { try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: recentSyncDate) // When - max age is 1 hour - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) - // Then - should not sync because site exists and time hasn't passed + await #expect(throws: POSCatalogSyncError.shouldNotSync) { + let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + } + #expect(mockSyncService.startFullSyncCallCount == 0) } @@ -199,6 +203,10 @@ struct POSCatalogSyncCoordinatorTests { #expect(error == POSCatalogSyncError.syncAlreadyInProgress(siteID: sampleSiteID)) } + let currentState = await sut.loadLastFullSyncState(for: sampleSiteID) + let isSyncStarted: Bool = if case .syncStarted = currentState { true } else { false } + #expect(isSyncStarted) + // Cleanup - resume the first sync and wait for it to complete mockSyncService.resumeBlockedSync() _ = try await firstSyncTask.value @@ -268,8 +276,8 @@ struct POSCatalogSyncCoordinatorTests { @Test func performIncrementalSyncIfApplicable_skips_sync_when_incremental_sync_is_within_max_age() async throws { // Given - let maxAge: TimeInterval = 2 - let incrementalSyncDate = Date().addingTimeInterval(-(maxAge - 0.2)) // Just within max age + let maxAge: TimeInterval = 60 // 60 seconds + let incrementalSyncDate = Date().addingTimeInterval(-30) // 30 seconds ago (within maxAge) let fullSyncDate = Date().addingTimeInterval(-7200) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate, lastIncrementalSyncDate: incrementalSyncDate) @@ -283,7 +291,7 @@ struct POSCatalogSyncCoordinatorTests { // When try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: maxAge) - // Then + // Then - should not sync because 30 seconds < 60 second maxAge #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) } @@ -527,6 +535,40 @@ struct POSCatalogSyncCoordinatorTests { } } + // MARK: - Full Sync State Monitoring Tests + + @Test func lastFullSyncState_returns_syncNeverDone_when_never_synced() async throws { + // Given - no previous sync for this site + // When - query state + let state = await sut.loadLastFullSyncState(for: sampleSiteID) + + // Then - should return syncNeverDone + #expect(state == .syncNeverDone(siteID: sampleSiteID)) + } + + @Test func lastFullSyncState_returns_syncCompleted_when_synced_before() async throws { + // Given - previous sync exists + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-3600)) + + // When - query state + let state = await sut.loadLastFullSyncState(for: sampleSiteID) + + // Then - should return syncCompleted + #expect(state == .syncCompleted(siteID: sampleSiteID)) + } + + @Test func fullSyncStateModel_emits_events_during_sync() async throws { + // Given + let expectedCatalog = POSCatalog(products: [], variations: [], syncDate: .now) + mockSyncService.startFullSyncResult = .success(expectedCatalog) + + // When - start sync and stream collection concurrently + try await sut.performFullSync(for: sampleSiteID) + + // Then - should emit syncStarted and syncCompleted with correct siteID + #expect(sut.fullSyncStateModel.state[sampleSiteID] == .syncCompleted(siteID: sampleSiteID)) + } + // MARK: - Helper Methods private func createSiteInDatabase(siteID: Int64, lastFullSyncDate: Date? = nil, lastIncrementalSyncDate: Date? = nil) throws { @@ -601,10 +643,11 @@ extension POSCatalogSyncCoordinatorTests { ) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) - // When - try await coordinator.performSmartSync(for: sampleSiteID) + // When / Then - sync should be skipped + await #expect(throws: POSCatalogSyncError.shouldNotSync) { + try await coordinator.performSmartSync(for: sampleSiteID) + } - // Then - sync should be skipped #expect(mockSyncService.startFullSyncCallCount == 0) #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) } diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 98a2c6f2aa7..481cff9b1fd 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -42,6 +42,8 @@ final class POSTabCoordinator { private let pushNotesManager: PushNotesManager private let eligibilityChecker: POSEntryPointEligibilityCheckerProtocol + private lazy var posSyncDispatcher = ForegroundPOSCatalogSyncDispatcher() + /// Local catalog eligibility service - created asynchronously during init private(set) var localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol? @@ -134,18 +136,27 @@ final class POSTabCoordinator { /// Only checks eligibility if the POS tab is visible func updatePOSEligibility(isPOSTabVisible: Bool) { Task { @MainActor [weak self] in - guard let self, let service = self.localCatalogEligibilityService else { return } + guard let self, let catalogEligibilityService = self.localCatalogEligibilityService else { return } // If POS tab is not visible, mark as ineligible guard isPOSTabVisible else { - try await service.updatePOSEligibility(isEligible: false, for: siteID) + try await catalogEligibilityService.updatePOSEligibility(isEligible: false, + for: siteID) + await posSyncDispatcher.stop() return } // Check actual POS eligibility using the eligibility checker let eligibilityState = await eligibilityChecker.checkEligibility() let isPOSEligible = eligibilityState == .eligible - try await service.updatePOSEligibility(isEligible: isPOSEligible, for: siteID) + do { + try await catalogEligibilityService.updatePOSEligibility(isEligible: isPOSEligible, + for: siteID) + // Only start syncs after we've updated the catalog eligibility. + await isPOSEligible ? posSyncDispatcher.start() : posSyncDispatcher.stop() + } catch { + await posSyncDispatcher.stop() + } } } diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift b/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift index 3cd19e35c86..cb94d1a7086 100644 --- a/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift +++ b/WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift @@ -10,7 +10,6 @@ import Experiments final actor ForegroundPOSCatalogSyncDispatcher { private enum Constants { static let syncInterval: TimeInterval = 60 * 60 // 1 hour - static let initialSyncDelay: TimeInterval = 5 // 5 seconds after becoming active static let leeway: Int = 5 // 5 seconds leeway to give a system more flexibility in managing resources } @@ -108,11 +107,11 @@ final actor ForegroundPOSCatalogSyncDispatcher { private func startTimer() { guard timer == nil else { return } - DDLogInfo("âąī¸ ForegroundPOSCatalogSyncDispatcher: Starting timer (interval: \(Int(interval))s, initial delay: \(Int(Constants.initialSyncDelay))s)") + DDLogInfo("âąī¸ ForegroundPOSCatalogSyncDispatcher: Starting timer (interval: \(Int(interval))s") let queue = DispatchQueue(label: "com.automattic.woocommerce.posCatalogForegroundSync.timer", qos: .utility) let timer = timerProvider.makeTimer(queue: queue) - timer.schedule(deadline: .now() + Constants.initialSyncDelay, repeating: interval, leeway: .seconds(Constants.leeway)) + timer.schedule(deadline: .now(), repeating: interval, leeway: .seconds(Constants.leeway)) timer.setEventHandler { [weak self] in Task { await self?.performSync() @@ -162,6 +161,8 @@ final actor ForegroundPOSCatalogSyncDispatcher { DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Invalid max age for site \(siteID)") case .requestCancelled: DDLogInfo("â„šī¸ ForegroundPOSCatalogSyncDispatcher: Sync request was cancelled for site \(siteID)") + case .shouldNotSync: + DDLogInfo("â„šī¸ ForegroundPOSCatalogSyncDispatcher: Should not sync site \(siteID) at this time") } } catch { DDLogError("â›”ī¸ ForegroundPOSCatalogSyncDispatcher: Sync failed for site \(siteID): \(error)") diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 952882b0509..bc50d8ec7c1 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -152,7 +152,6 @@ final class MainTabBarController: UITabBarController { private var posTabVisibilityChecker: POSTabVisibilityCheckerProtocol? private var posEligibilityCheckTask: Task? - private lazy var posSyncDispatcher = ForegroundPOSCatalogSyncDispatcher() /// periphery: ignore - keeping strong ref of the checker to keep its async task alive private var bookingsEligibilityChecker: BookingsTabEligibilityCheckerProtocol? @@ -752,9 +751,6 @@ private extension MainTabBarController { // Update POS eligibility - coordinator will check actual eligibility if tab is visible posTabCoordinator?.updatePOSEligibility(isPOSTabVisible: isPOSTabVisible) - - // Begin foreground synchronization if POS tab becomes visible - await isPOSTabVisible ? posSyncDispatcher.start() : posSyncDispatcher.stop() } } diff --git a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift index 61641546548..1987a72bd13 100644 --- a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift @@ -291,4 +291,10 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { // Not used } + + let fullSyncStateModel = POSCatalogSyncStateModel() + + func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { + return fullSyncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID) + } }