Skip to content

Commit ea5ebd9

Browse files
authored
[Local catalog] Catalog Needs Refreshing Warning (#16309)
2 parents 895a44a + 8b959bf commit ea5ebd9

File tree

10 files changed

+220
-14
lines changed

10 files changed

+220
-14
lines changed

Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ protocol PointOfSaleAggregateModelProtocol {
6060
private var cardReaderDisconnection: AnyCancellable?
6161

6262
private let soundPlayer: PointOfSaleSoundPlayerProtocol
63+
private let isLocalCatalogEligible: Bool
6364

6465
private var cancellables: Set<AnyCancellable> = []
6566

@@ -76,6 +77,18 @@ protocol PointOfSaleAggregateModelProtocol {
7677
_viewStateCoordinator
7778
}
7879

80+
// Track stale sync warning (only relevant when using local catalog)
81+
var isSyncStale: Bool = false
82+
var isStaleSyncWarningDismissed: Bool = false
83+
84+
var showStaleSyncWarning: Bool {
85+
// Only show warning if using local catalog
86+
guard isLocalCatalogEligible else {
87+
return false
88+
}
89+
return isSyncStale && !isStaleSyncWarningDismissed
90+
}
91+
7992
init(entryPointController: POSEntryPointController,
8093
itemsController: PointOfSaleItemsControllerProtocol,
8194
purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol,
@@ -92,7 +105,8 @@ protocol PointOfSaleAggregateModelProtocol {
92105
soundPlayer: PointOfSaleSoundPlayerProtocol = PointOfSaleSoundPlayer(),
93106
paymentState: PointOfSalePaymentState = .idle,
94107
siteID: Int64,
95-
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil) {
108+
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil,
109+
isLocalCatalogEligible: Bool = false) {
96110
self.entryPointController = entryPointController
97111
self.purchasableItemsController = itemsController
98112
self.purchasableItemsSearchController = purchasableItemsSearchController
@@ -110,6 +124,7 @@ protocol PointOfSaleAggregateModelProtocol {
110124
self.soundPlayer = soundPlayer
111125
self.siteID = siteID
112126
self.catalogSyncCoordinator = catalogSyncCoordinator
127+
self.isLocalCatalogEligible = isLocalCatalogEligible
113128

114129
publishCardReaderConnectionStatus()
115130
publishPaymentMessages()
@@ -632,6 +647,28 @@ private extension PointOfSaleAggregateModel {
632647
}
633648
}
634649

650+
// MARK: - Stale Sync Warning
651+
extension PointOfSaleAggregateModel {
652+
var staleSyncThresholdDays: Int {
653+
Constants.staleSyncThresholdDays
654+
}
655+
656+
func dismissStaleSyncWarning() {
657+
isStaleSyncWarningDismissed = true
658+
}
659+
660+
func checkStaleSyncStatus() async {
661+
guard let catalogSyncCoordinator else { return }
662+
isSyncStale = await catalogSyncCoordinator.isSyncStale(for: siteID, maxDays: Constants.staleSyncThresholdDays)
663+
}
664+
}
665+
666+
// MARK: - Constants
667+
private enum Constants {
668+
/// Number of days before showing a stale catalog sync warning
669+
static let staleSyncThresholdDays: Int = 7
670+
}
671+
635672
#if DEBUG
636673
extension PointOfSaleAggregateModel {
637674
func setPreviewState(paymentState: PointOfSalePaymentState, inlineMessage: PointOfSaleCardPresentPaymentMessageType?) {

Modules/Sources/PointOfSale/Presentation/ItemListView.swift

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,20 +161,49 @@ struct ItemListView: View {
161161

162162
@ViewBuilder
163163
private func listView(itemListType: ItemListType) -> some View {
164-
ItemList(
165-
itemsController: itemsController(itemListType),
166-
node: .root,
167-
itemActionHandler: actionHandler(itemListType),
168-
willLoadMore: {
169-
analyticsTracker.trackNextPageWillLoad()
164+
VStack(spacing: 0) {
165+
// Stale sync warning banner
166+
if posModel.showStaleSyncWarning {
167+
staleSyncWarningBanner
168+
.padding(.horizontal, POSPadding.medium)
169+
.padding(.vertical, POSPadding.small)
170+
.transition(.move(edge: .top).combined(with: .opacity))
170171
}
171-
)
172-
.refreshable {
173-
analyticsTracker.trackRefresh()
174-
await itemsController(itemListType).refreshItems(base: .root)
172+
173+
ItemList(
174+
itemsController: itemsController(itemListType),
175+
node: .root,
176+
itemActionHandler: actionHandler(itemListType),
177+
willLoadMore: {
178+
analyticsTracker.trackNextPageWillLoad()
179+
}
180+
)
181+
.refreshable {
182+
analyticsTracker.trackRefresh()
183+
await itemsController(itemListType).refreshItems(base: .root)
184+
}
185+
}
186+
.task {
187+
// Check stale sync status when view appears
188+
await posModel.checkStaleSyncStatus()
175189
}
176190
}
177191

192+
@ViewBuilder
193+
private var staleSyncWarningBanner: some View {
194+
POSNoticeView(
195+
title: Localization.staleSyncWarningTitle,
196+
icon: Image(systemName: "info.circle"),
197+
onDismiss: {
198+
withAnimation {
199+
posModel.dismissStaleSyncWarning()
200+
}
201+
}, content: {
202+
Text(Localization.staleSyncWarningDescription(days: posModel.staleSyncThresholdDays))
203+
.font(POSFontStyle.posBodyMediumRegular())
204+
})
205+
}
206+
178207
private func actionHandler(_ itemListType: ItemListType) -> POSItemActionHandler {
179208
POSItemActionHandlerFactory.itemActionHandler(
180209
itemListType: itemListType,
@@ -400,6 +429,23 @@ private extension ItemListView {
400429
value: "Coupons",
401430
comment: "Title of the button at the top of Point of Sale to switch to Coupons list."
402431
)
432+
433+
static let staleSyncWarningTitle = NSLocalizedString(
434+
"pos.itemlistview.staleSyncWarning.title",
435+
value: "Refresh catalog",
436+
comment: "Warning title shown when the product catalog hasn't synced in several days"
437+
)
438+
439+
static let staleSyncWarningDescriptionFormat = NSLocalizedString(
440+
"pos.itemlistview.staleSyncWarning.description",
441+
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.",
442+
comment: "Message shown when the product catalog hasn't synced in the specified number of days. " +
443+
"%1$ld will be replaced with the number of days. Reads like: The catalog hasn't been synced in the last 7 days."
444+
)
445+
446+
static func staleSyncWarningDescription(days: Int) -> String {
447+
String.localizedStringWithFormat(staleSyncWarningDescriptionFormat, days)
448+
}
403449
}
404450
}
405451

Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ struct PointOfSaleDashboardView: View {
143143
.posFullScreenCover(isPresented: $showSettings) {
144144
POSSettingsView(settingsController: posModel.settingsController)
145145
}
146+
.onChange(of: showSettings) { oldValue, newValue in
147+
guard !newValue, oldValue else { return }
148+
Task {
149+
await posModel.checkStaleSyncStatus()
150+
}
151+
}
146152
.onChange(of: posModel.entryPointController.eligibilityState) { oldValue, newValue in
147153
guard newValue == .eligible else { return }
148154
Task { @MainActor in

Modules/Sources/PointOfSale/Presentation/PointOfSaleEntryPointView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public struct PointOfSaleEntryPointView: View {
4242
private let services: POSDependencyProviding
4343
private let siteID: Int64
4444
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?
45+
private let isLocalCatalogEligible: Bool
4546

4647
/// periphery: ignore - public in preparation of move to POS module
4748
public init(siteID: Int64,
@@ -132,6 +133,7 @@ public struct PointOfSaleEntryPointView: View {
132133
self.services = services
133134
self.siteID = siteID
134135
self.catalogSyncCoordinator = catalogSyncCoordinator
136+
self.isLocalCatalogEligible = isLocalCatalogEligible
135137
}
136138

137139
public var body: some View {
@@ -162,7 +164,8 @@ public struct PointOfSaleEntryPointView: View {
162164
popularPurchasableItemsController: popularPurchasableItemsController,
163165
barcodeScanService: barcodeScanService,
164166
siteID: siteID,
165-
catalogSyncCoordinator: catalogSyncCoordinator)
167+
catalogSyncCoordinator: catalogSyncCoordinator,
168+
isLocalCatalogEligible: isLocalCatalogEligible)
166169
}
167170
.environment(\.posAnalytics, services.analytics)
168171
.environment(\.posCurrencyProvider, services.currency)

Modules/Sources/PointOfSale/Presentation/Reusable Views/POSNoticeView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ struct POSNoticeView<Content: View>: View {
4545
.accessibilityElement(children: .combine)
4646
}
4747
}
48+
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
4849
.frame(maxWidth: .infinity, alignment: .leading)
4950

5051
if let onDismiss {

Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ struct POSPreviewHelpers {
217217
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol = PointOfSalePreviewBarcodeScanService(),
218218
analytics: POSAnalyticsProviding = EmptyPOSAnalytics(),
219219
siteID: Int64 = 1,
220-
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil
220+
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? = nil,
221+
isLocalCatalogEligible: Bool = false
221222
) -> PointOfSaleAggregateModel {
222223
return PointOfSaleAggregateModel(
223224
entryPointController: POSEntryPointController(eligibilityChecker: PointOfSalePreviewTabEligibilityChecker()),
@@ -234,7 +235,8 @@ struct POSPreviewHelpers {
234235
popularPurchasableItemsController: popularItemsController,
235236
barcodeScanService: barcodeScanService,
236237
siteID: siteID,
237-
catalogSyncCoordinator: catalogSyncCoordinator
238+
catalogSyncCoordinator: catalogSyncCoordinator,
239+
isLocalCatalogEligible: isLocalCatalogEligible
238240
)
239241
}
240242

@@ -635,6 +637,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
635637
func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState {
636638
return fullSyncStateModel.state[siteID] ?? .syncCompleted(siteID: siteID)
637639
}
640+
641+
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
642+
return false
643+
}
638644
}
639645

640646
#endif

Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ public protocol POSCatalogSyncCoordinatorProtocol {
3535
/// Returns the last known full sync state for a site
3636
/// If no state is cached, determines state from lastSyncDate
3737
func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState
38+
39+
/// Checks if the last sync is older than the specified number of days
40+
/// - Parameters:
41+
/// - siteID: The site ID to check
42+
/// - maxDays: Maximum number of days before a sync is considered stale
43+
/// - Returns: True if the last sync is older than the specified days or if there has been no sync
44+
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool
3845
}
3946

4047
public extension POSCatalogSyncCoordinatorProtocol {
@@ -309,6 +316,21 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
309316
fullSyncStateModel.state[siteID] = state
310317
return state
311318
}
319+
320+
public func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
321+
// Check only the last full sync date, incremental syncs don't refresh well enough to consider non-stale.
322+
guard let lastFullSync = await lastFullSyncDate(for: siteID) else {
323+
// If we've never done a full sync, we're stale.
324+
return true
325+
}
326+
327+
guard let thresholdDate = Calendar.current.date(byAdding: .day, value: -maxDays, to: Date()) else {
328+
// This shouldn't fail, and if it does, we can assume the catalog is fine
329+
return false
330+
}
331+
332+
return lastFullSync < thresholdDate
333+
}
312334
}
313335

314336
// MARK: - Syncing State

Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,10 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
6868
func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState {
6969
return fullSyncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID)
7070
}
71+
72+
var isSyncStaleResult: Bool = false
73+
74+
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
75+
return isSyncStaleResult
76+
}
7177
}

Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,81 @@ extension POSCatalogSyncCoordinatorTests {
860860
// Then - sync should proceed (exactly at 30-day boundary is still eligible)
861861
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1)
862862
}
863+
864+
// MARK: - isSyncStale Tests
865+
866+
@Test func isSyncStale_returns_true_when_no_full_sync_performed() async throws {
867+
// Given - no full sync date set
868+
869+
// When
870+
let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7)
871+
872+
// Then
873+
#expect(isStale == true)
874+
}
875+
876+
@Test func isSyncStale_returns_false_when_full_sync_is_recent() async throws {
877+
// Given - last full sync was 3 days ago
878+
let threeDaysAgo = try #require(Calendar.current.date(byAdding: .day, value: -3, to: Date()))
879+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: threeDaysAgo)
880+
881+
// When
882+
let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7)
883+
884+
// Then
885+
#expect(isStale == false)
886+
}
887+
888+
@Test func isSyncStale_returns_true_when_full_sync_is_old() async throws {
889+
// Given - last full sync was 10 days ago
890+
let tenDaysAgo = try #require(Calendar.current.date(byAdding: .day, value: -10, to: Date()))
891+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: tenDaysAgo)
892+
893+
// When
894+
let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7)
895+
896+
// Then
897+
#expect(isStale == true)
898+
}
899+
900+
@Test func isSyncStale_ignores_incremental_sync_date() async throws {
901+
// Given - incremental sync was recent, but full sync was old
902+
let yesterday = try #require(Calendar.current.date(byAdding: .day, value: -1, to: Date()))
903+
let tenDaysAgo = try #require(Calendar.current.date(byAdding: .day, value: -10, to: Date()))
904+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: tenDaysAgo, lastIncrementalSyncDate: yesterday)
905+
906+
// When
907+
let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7)
908+
909+
// Then - should only check full sync date
910+
#expect(isStale == true)
911+
}
912+
913+
@Test func isSyncStale_boundary_within_threshold() async throws {
914+
// Given - last full sync was 6 days and 23 hours ago (just under 7 days)
915+
let justUnderSevenDays = try #require(Calendar.current.date(byAdding: .day, value: -6, to: Date()))
916+
.addingTimeInterval(-23 * 60 * 60) // minus 23 hours
917+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: justUnderSevenDays)
918+
919+
// When
920+
let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7)
921+
922+
// Then - just under threshold should not be stale
923+
#expect(isStale == false)
924+
}
925+
926+
@Test func isSyncStale_boundary_past_threshold() async throws {
927+
// Given - last full sync was 7 days and 1 second ago (just past 7 days)
928+
let justPastSevenDays = try #require(Calendar.current.date(byAdding: .day, value: -7, to: Date()))
929+
.addingTimeInterval(-1)
930+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: justPastSevenDays)
931+
932+
// When
933+
let isStale = await sut.isSyncStale(for: sampleSiteID, maxDays: 7)
934+
935+
// Then - past threshold should be stale
936+
#expect(isStale == true)
937+
}
863938
}
864939

865940
extension POSCatalogSyncCoordinator {

WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,8 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt
297297
func loadLastFullSyncState(for siteID: Int64) async -> POSCatalogSyncState {
298298
return fullSyncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID)
299299
}
300+
301+
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
302+
return false
303+
}
300304
}

0 commit comments

Comments
 (0)