diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index 815b0e63f1d..0c347fa9cd5 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -60,6 +60,7 @@ protocol PointOfSaleAggregateModelProtocol { private var cardReaderDisconnection: AnyCancellable? private let soundPlayer: PointOfSaleSoundPlayerProtocol + private let isLocalCatalogEligible: Bool private var cancellables: Set = [] @@ -76,6 +77,18 @@ protocol PointOfSaleAggregateModelProtocol { _viewStateCoordinator } + // Track stale sync warning (only relevant when using local catalog) + var isSyncStale: Bool = false + var isStaleSyncWarningDismissed: Bool = false + + var showStaleSyncWarning: Bool { + // Only show warning if using local catalog + guard isLocalCatalogEligible else { + return false + } + return isSyncStale && !isStaleSyncWarningDismissed + } + init(entryPointController: POSEntryPointController, itemsController: PointOfSaleItemsControllerProtocol, purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol, @@ -92,7 +105,8 @@ protocol PointOfSaleAggregateModelProtocol { soundPlayer: PointOfSaleSoundPlayerProtocol = PointOfSaleSoundPlayer(), paymentState: PointOfSalePaymentState = .idle, siteID: Int64, - catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil) { + catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil, + isLocalCatalogEligible: Bool = false) { self.entryPointController = entryPointController self.purchasableItemsController = itemsController self.purchasableItemsSearchController = purchasableItemsSearchController @@ -110,6 +124,7 @@ protocol PointOfSaleAggregateModelProtocol { self.soundPlayer = soundPlayer self.siteID = siteID self.catalogSyncCoordinator = catalogSyncCoordinator + self.isLocalCatalogEligible = isLocalCatalogEligible publishCardReaderConnectionStatus() publishPaymentMessages() @@ -632,6 +647,28 @@ private extension PointOfSaleAggregateModel { } } +// MARK: - Stale Sync Warning +extension PointOfSaleAggregateModel { + var staleSyncThresholdDays: Int { + Constants.staleSyncThresholdDays + } + + func dismissStaleSyncWarning() { + isStaleSyncWarningDismissed = true + } + + func checkStaleSyncStatus() async { + guard let catalogSyncCoordinator else { return } + isSyncStale = await catalogSyncCoordinator.isSyncStale(for: siteID, maxDays: Constants.staleSyncThresholdDays) + } +} + +// MARK: - Constants +private enum Constants { + /// Number of days before showing a stale catalog sync warning + static let staleSyncThresholdDays: Int = 7 +} + #if DEBUG extension PointOfSaleAggregateModel { func setPreviewState(paymentState: PointOfSalePaymentState, inlineMessage: PointOfSaleCardPresentPaymentMessageType?) { diff --git a/Modules/Sources/PointOfSale/Presentation/ItemListView.swift b/Modules/Sources/PointOfSale/Presentation/ItemListView.swift index 48a1d6260d5..db206589d6b 100644 --- a/Modules/Sources/PointOfSale/Presentation/ItemListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/ItemListView.swift @@ -161,20 +161,49 @@ struct ItemListView: View { @ViewBuilder private func listView(itemListType: ItemListType) -> some View { - ItemList( - itemsController: itemsController(itemListType), - node: .root, - itemActionHandler: actionHandler(itemListType), - willLoadMore: { - analyticsTracker.trackNextPageWillLoad() + VStack(spacing: 0) { + // Stale sync warning banner + if posModel.showStaleSyncWarning { + staleSyncWarningBanner + .padding(.horizontal, POSPadding.medium) + .padding(.vertical, POSPadding.small) + .transition(.move(edge: .top).combined(with: .opacity)) } - ) - .refreshable { - analyticsTracker.trackRefresh() - await itemsController(itemListType).refreshItems(base: .root) + + ItemList( + itemsController: itemsController(itemListType), + node: .root, + itemActionHandler: actionHandler(itemListType), + willLoadMore: { + analyticsTracker.trackNextPageWillLoad() + } + ) + .refreshable { + analyticsTracker.trackRefresh() + await itemsController(itemListType).refreshItems(base: .root) + } + } + .task { + // Check stale sync status when view appears + await posModel.checkStaleSyncStatus() } } + @ViewBuilder + private var staleSyncWarningBanner: some View { + POSNoticeView( + title: Localization.staleSyncWarningTitle, + icon: Image(systemName: "info.circle"), + onDismiss: { + withAnimation { + posModel.dismissStaleSyncWarning() + } + }, content: { + Text(Localization.staleSyncWarningDescription(days: posModel.staleSyncThresholdDays)) + .font(POSFontStyle.posBodyMediumRegular()) + }) + } + private func actionHandler(_ itemListType: ItemListType) -> POSItemActionHandler { POSItemActionHandlerFactory.itemActionHandler( itemListType: itemListType, @@ -400,6 +429,23 @@ private extension ItemListView { value: "Coupons", comment: "Title of the button at the top of Point of Sale to switch to Coupons list." ) + + static let staleSyncWarningTitle = NSLocalizedString( + "pos.itemlistview.staleSyncWarning.title", + value: "Refresh catalog", + comment: "Warning title shown when the product catalog hasn't synced in several days" + ) + + static let staleSyncWarningDescriptionFormat = NSLocalizedString( + "pos.itemlistview.staleSyncWarning.description", + value: "The catalog hasn't been synced in the last %1$ld days. Please ensure you're connected to the internet and sync again in POS Settings.", + comment: "Message shown when the product catalog hasn't synced in the specified number of days. " + + "%1$ld will be replaced with the number of days. Reads like: The catalog hasn't been synced in the last 7 days." + ) + + static func staleSyncWarningDescription(days: Int) -> String { + String.localizedStringWithFormat(staleSyncWarningDescriptionFormat, days) + } } } diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift index 67ab07385c1..d9442228dd8 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift @@ -143,6 +143,12 @@ struct PointOfSaleDashboardView: View { .posFullScreenCover(isPresented: $showSettings) { PointOfSaleSettingsView(settingsController: posModel.settingsController) } + .onChange(of: showSettings) { oldValue, newValue in + guard !newValue, oldValue else { return } + Task { + await posModel.checkStaleSyncStatus() + } + } .onChange(of: posModel.entryPointController.eligibilityState) { oldValue, newValue in guard newValue == .eligible else { return } Task { @MainActor in diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift index 4c41fb2eb99..66aaaa45c56 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift @@ -42,6 +42,7 @@ public struct PointOfSaleEntryPointView: View { private let services: POSDependencyProviding private let siteID: Int64 private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? + private let isLocalCatalogEligible: Bool /// periphery: ignore - public in preparation of move to POS module public init(siteID: Int64, @@ -132,6 +133,7 @@ public struct PointOfSaleEntryPointView: View { self.services = services self.siteID = siteID self.catalogSyncCoordinator = catalogSyncCoordinator + self.isLocalCatalogEligible = isLocalCatalogEligible } public var body: some View { @@ -162,7 +164,8 @@ public struct PointOfSaleEntryPointView: View { popularPurchasableItemsController: popularPurchasableItemsController, barcodeScanService: barcodeScanService, siteID: siteID, - catalogSyncCoordinator: catalogSyncCoordinator) + catalogSyncCoordinator: catalogSyncCoordinator, + isLocalCatalogEligible: isLocalCatalogEligible) } .environment(\.posAnalytics, services.analytics) .environment(\.posCurrencyProvider, services.currency) diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift index 7906cb8627e..43788072034 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift @@ -45,6 +45,7 @@ struct POSNoticeView: View { .accessibilityElement(children: .combine) } } + .dynamicTypeSize(...DynamicTypeSize.accessibility2) .frame(maxWidth: .infinity, alignment: .leading) if let onDismiss { diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 09edde4fab4..d884e797a95 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -217,7 +217,8 @@ struct POSPreviewHelpers { barcodeScanService: PointOfSaleBarcodeScanServiceProtocol = PointOfSalePreviewBarcodeScanService(), analytics: POSAnalyticsProviding = EmptyPOSAnalytics(), siteID: Int64 = 1, - catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil + catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil, + isLocalCatalogEligible: Bool = false ) -> PointOfSaleAggregateModel { return PointOfSaleAggregateModel( entryPointController: POSEntryPointController(eligibilityChecker: PointOfSalePreviewTabEligibilityChecker()), @@ -234,7 +235,8 @@ struct POSPreviewHelpers { popularPurchasableItemsController: popularItemsController, barcodeScanService: barcodeScanService, siteID: siteID, - catalogSyncCoordinator: catalogSyncCoordinator + catalogSyncCoordinator: catalogSyncCoordinator, + isLocalCatalogEligible: isLocalCatalogEligible ) } @@ -635,6 +637,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { return fullSyncStateModel.state[siteID] ?? .syncCompleted(siteID: siteID) } + + func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool { + return false + } } #endif diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 86f2e987d7d..16cfcdfdc72 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -35,6 +35,13 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// 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 + + /// Checks if the last sync is older than the specified number of days + /// - Parameters: + /// - siteID: The site ID to check + /// - maxDays: Maximum number of days before a sync is considered stale + /// - Returns: True if the last sync is older than the specified days or if there has been no sync + func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool } public extension POSCatalogSyncCoordinatorProtocol { @@ -309,6 +316,21 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { fullSyncStateModel.state[siteID] = state return state } + + public func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool { + // Check only the last full sync date, incremental syncs don't refresh well enough to consider non-stale. + guard let lastFullSync = await lastFullSyncDate(for: siteID) else { + // If we've never done a full sync, we're stale. + return true + } + + guard let thresholdDate = Calendar.current.date(byAdding: .day, value: -maxDays, to: Date()) else { + // This shouldn't fail, and if it does, we can assume the catalog is fine + return false + } + + return lastFullSync < thresholdDate + } } // MARK: - Syncing State diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index c4c9095cfd1..198060f92a5 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -68,4 +68,10 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { return fullSyncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID) } + + var isSyncStaleResult: Bool = false + + func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool { + return isSyncStaleResult + } } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index beb6dd56d47..db5c81899da 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -860,6 +860,81 @@ extension POSCatalogSyncCoordinatorTests { // Then - sync should proceed (exactly at 30-day boundary is still eligible) #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) } + + // MARK: - isSyncStale Tests + + @Test func isSyncStale_returns_true_when_no_full_sync_performed() async throws { + // Given - no full sync date set + + // When + let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7) + + // Then + #expect(isStale == true) + } + + @Test func isSyncStale_returns_false_when_full_sync_is_recent() async throws { + // Given - last full sync was 3 days ago + let threeDaysAgo = try #require(Calendar.current.date(byAdding: .day, value: -3, to: Date())) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: threeDaysAgo) + + // When + let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7) + + // Then + #expect(isStale == false) + } + + @Test func isSyncStale_returns_true_when_full_sync_is_old() async throws { + // Given - last full sync was 10 days ago + let tenDaysAgo = try #require(Calendar.current.date(byAdding: .day, value: -10, to: Date())) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: tenDaysAgo) + + // When + let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7) + + // Then + #expect(isStale == true) + } + + @Test func isSyncStale_ignores_incremental_sync_date() async throws { + // Given - incremental sync was recent, but full sync was old + let yesterday = try #require(Calendar.current.date(byAdding: .day, value: -1, to: Date())) + let tenDaysAgo = try #require(Calendar.current.date(byAdding: .day, value: -10, to: Date())) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: tenDaysAgo, lastIncrementalSyncDate: yesterday) + + // When + let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7) + + // Then - should only check full sync date + #expect(isStale == true) + } + + @Test func isSyncStale_boundary_within_threshold() async throws { + // Given - last full sync was 6 days and 23 hours ago (just under 7 days) + let justUnderSevenDays = try #require(Calendar.current.date(byAdding: .day, value: -6, to: Date())) + .addingTimeInterval(-23 * 60 * 60) // minus 23 hours + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: justUnderSevenDays) + + // When + let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7) + + // Then - just under threshold should not be stale + #expect(isStale == false) + } + + @Test func isSyncStale_boundary_past_threshold() async throws { + // Given - last full sync was 7 days and 1 second ago (just past 7 days) + let justPastSevenDays = try #require(Calendar.current.date(byAdding: .day, value: -7, to: Date())) + .addingTimeInterval(-1) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: justPastSevenDays) + + // When + let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7) + + // Then - past threshold should be stale + #expect(isStale == true) + } } extension POSCatalogSyncCoordinator { diff --git a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift index 6c85248ab79..4fde055d5d5 100644 --- a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift @@ -297,4 +297,8 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState { return fullSyncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID) } + + func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool { + return false + } }