Skip to content

Commit 895a44a

Browse files
authored
[Local catalog] Error screen for missing catalog when opening POS (#16305)
2 parents 99ed236 + c711239 commit 895a44a

File tree

11 files changed

+359
-29
lines changed

11 files changed

+359
-29
lines changed

Modules/Sources/PointOfSale/Controllers/PointOfSaleObservableItemsController.swift

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt
6363
func loadItems(base: ItemListBaseItem) async {
6464
switch base {
6565
case .root:
66-
if shouldRefresh(for: base) {
66+
if await shouldReload(for: base) {
67+
await reloadItems(base: base)
68+
} else if shouldRefresh(for: base) {
6769
await refreshItems(base: base)
6870
}
6971
dataSource.loadProducts()
@@ -88,6 +90,15 @@ final class PointOfSaleObservableItemsController: PointOfSaleItemsControllerProt
8890
}
8991
}
9092

93+
func reloadItems(base: ItemListBaseItem) async {
94+
guard case .root = base else {
95+
return
96+
}
97+
loadingState = .init()
98+
99+
try? await catalogSyncCoordinator.performSmartSync(for: siteID)
100+
}
101+
91102
func refreshItems(base: ItemListBaseItem) async {
92103
if itemsEmpty(for: base) {
93104
refreshState = .loading
@@ -125,9 +136,14 @@ private extension PointOfSaleObservableItemsController {
125136
return .loading(isCatalogSyncing: true)
126137
}
127138

139+
if case .failure(let error) = initialSyncResult {
140+
return .error(.errorOnInitalCatalogSync(error: error))
141+
}
142+
128143
if !loadingState.productsLoaded && dataSource.isLoadingProducts {
129144
return .loading()
130145
}
146+
131147
return .content
132148
}
133149

@@ -137,13 +153,27 @@ private extension PointOfSaleObservableItemsController {
137153
}
138154

139155
switch syncState {
140-
case .syncStarted(_, true), .syncNeverDone:
156+
case .initialSyncStarted, .syncNeverDone:
141157
return true
142158
default:
143159
return false
144160
}
145161
}
146162

163+
var initialSyncResult: Result<Void, Error>? {
164+
switch catalogSyncCoordinator.fullSyncStateModel.state[siteID] {
165+
case .initialSyncFailed(_, let error):
166+
return .failure(error)
167+
case .syncFailed(_, let error):
168+
// If there's no catalog data, treat subsequent sync failures as critical
169+
return dataSource.productItems.isEmpty ? .failure(error) : .success(())
170+
case .syncCompleted:
171+
return .success(())
172+
default:
173+
return nil
174+
}
175+
}
176+
147177
var rootState: ItemListState {
148178
computeItemListState(
149179
items: dataSource.productItems,
@@ -173,6 +203,19 @@ private extension PointOfSaleObservableItemsController {
173203
}
174204

175205
private extension PointOfSaleObservableItemsController {
206+
func shouldReload(for type: ItemListBaseItem) async -> Bool {
207+
guard case .root = type else {
208+
return false
209+
}
210+
211+
// Reload if there's a failure
212+
if case .failure = initialSyncResult {
213+
return true
214+
}
215+
216+
return false
217+
}
218+
176219
/// Determines if a refresh should be triggered
177220
func shouldRefresh(for type: ItemListBaseItem) -> Bool {
178221
if case .error = refreshState {

Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ protocol PointOfSaleAggregateModelProtocol {
115115
publishPaymentMessages()
116116
setupReaderReconnectionObservation()
117117
setupPaymentSuccessObservation()
118-
performIncrementalSync()
118+
performInitialSyncIfNeeded()
119119
}
120120
}
121121

@@ -623,6 +623,13 @@ private extension PointOfSaleAggregateModel {
623623
try? await catalogSyncCoordinator.performIncrementalSync(for: siteID)
624624
}
625625
}
626+
627+
private func performInitialSyncIfNeeded() {
628+
guard let catalogSyncCoordinator else { return }
629+
Task {
630+
try? await catalogSyncCoordinator.performSmartSync(for: siteID)
631+
}
632+
}
626633
}
627634

628635
#if DEBUG

Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import enum Alamofire.AFError
33

44
struct PointOfSaleErrorState: Equatable {
55
enum ErrorType: Equatable {
6+
case initialCatalogSyncError
67
case productsLoadError
78
case variationsLoadError
89
case productsNextPageError
@@ -108,6 +109,14 @@ struct PointOfSaleErrorState: Equatable {
108109
buttonText: Constants.retryButtonTitle)
109110
}
110111

112+
static func errorOnInitalCatalogSync(error: Error? = nil) -> Self {
113+
PointOfSaleErrorState(
114+
errorType: .initialCatalogSyncError,
115+
title: Constants.failedToSyncCatalogTitle,
116+
subtitle: subtitle(for: error),
117+
buttonText: Constants.retryButtonTitle)
118+
}
119+
111120
private static func subtitle(for error: Error?) -> String {
112121
if let error, error.isConnectivityError {
113122
return Constants.connectivityErrorSubtitle
@@ -127,6 +136,11 @@ struct PointOfSaleErrorState: Equatable {
127136
value: "Retry",
128137
comment: "Generic text for retry buttons appearing on error screens."
129138
)
139+
static let failedToSyncCatalogTitle = NSLocalizedString(
140+
"pos.itemList.syncCatalogErrorTitle",
141+
value: "Unable to sync catalog",
142+
comment: "Title appearing on the item list screen when there's an error syncing the catalog for the first time."
143+
)
130144
static let loadingCouponsErrorTitle = NSLocalizedString(
131145
"pos.itemList.loadingCouponsErrorTitle.2",
132146
value: "Unable to load coupons",

Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleItemListFullscreenErrorView.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@ import SwiftUI
44
struct PointOfSaleItemListFullscreenErrorView: View {
55
private let error: PointOfSaleErrorState
66
private let onAction: (() -> Void)?
7+
private let onExit: (() -> Void)?
78

8-
init(error: PointOfSaleErrorState, onAction: (() -> Void)? = nil) {
9+
init(error: PointOfSaleErrorState, onAction: (() -> Void)? = nil, onExit: (() -> Void)? = nil) {
910
self.error = error
1011
self.onAction = onAction
12+
self.onExit = onExit
1113
}
1214

1315
var body: some View {
14-
PointOfSaleItemListFullscreenView {
15-
POSListErrorView(error: error, onAction: onAction)
16+
PointOfSaleItemListFullscreenView(showTitle: !isInitialCatalogSyncError) {
17+
POSListErrorView(error: error, onAction: onAction, onExit: onExit)
1618
}
1719
}
20+
21+
// TODO: WOOMOB-1692 remove specialisation of errors if possible
22+
private var isInitialCatalogSyncError: Bool {
23+
error.errorType == .initialCatalogSyncError
24+
}
1825
}
1926

2027
#Preview {

Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleItemListFullscreenView.swift

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import SwiftUI
22

33
struct PointOfSaleItemListFullscreenView<Content: View>: View {
4+
let showTitle: Bool
45
let content: () -> Content
56

7+
init(showTitle: Bool = true, @ViewBuilder content: @escaping () -> Content) {
8+
self.showTitle = showTitle
9+
self.content = content
10+
}
11+
612
var body: some View {
713
ZStack {
8-
VStack(alignment: .center, spacing: PointOfSaleItemListErrorLayout.headerSpacing) {
9-
POSHeaderTitleView(
10-
title: Localization.title,
11-
foregroundColor: .posOnSurfaceVariantHighest
12-
)
13-
Spacer()
14+
// TODO: WOOMOB-1692 remove specialisation of errors if possible
15+
if showTitle {
16+
VStack(alignment: .center, spacing: PointOfSaleItemListErrorLayout.headerSpacing) {
17+
POSHeaderTitleView(
18+
title: Localization.title,
19+
foregroundColor: .posOnSurfaceVariantHighest
20+
)
21+
Spacer()
22+
}
1423
}
1524

1625
content()

Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ struct PointOfSaleDashboardView: View {
8585
await posModel.couponsSearchController.loadItems(base: .root)
8686
}
8787
}
88-
})
88+
}, onExit: error.errorType == .initialCatalogSyncError ? { // TODO: WOOMOB-1692 remove specialisation of errors if possible
89+
dismiss()
90+
} : nil)
8991
case .content:
9092
contentView
9193
.accessibilitySortPriority(2)

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ struct POSListErrorView: View {
66
@Environment(\.floatingControlAreaSize) private var floatingControlAreaSize: CGSize
77
private let viewModel: POSListErrorViewModel
88
private let onAction: (() -> Void)?
9+
private let onExit: (() -> Void)?
910

1011
@State private var viewWidth: CGFloat = 0
1112

1213
@Environment(\.keyboardObserver) private var keyboard
1314

14-
init(error: PointOfSaleErrorState, onAction: (() -> Void)? = nil) {
15+
init(error: PointOfSaleErrorState, onAction: (() -> Void)? = nil, onExit: (() -> Void)? = nil) {
1516
self.viewModel = POSListErrorViewModel(error: error)
1617
self.onAction = onAction
18+
self.onExit = onExit
1719
}
1820

1921
var body: some View {
@@ -58,6 +60,18 @@ struct POSListErrorView: View {
5860
.frame(width: viewWidth / 2)
5961
.padding([.leading, .trailing])
6062
}
63+
64+
if let onExit {
65+
Spacer().frame(height: POSSpacing.medium)
66+
Button(action: {
67+
onExit()
68+
}, label: {
69+
Text(Localization.exitButtonText)
70+
})
71+
.buttonStyle(POSOutlinedButtonStyle(size: .normal))
72+
.frame(width: viewWidth / 2)
73+
.padding([.leading, .trailing])
74+
}
6175
}
6276
Spacer()
6377
}
@@ -87,6 +101,14 @@ struct POSListErrorViewModel {
87101
}
88102
}
89103

104+
private enum Localization {
105+
static let exitButtonText = NSLocalizedString(
106+
"pos.listError.exitButton",
107+
value: "Exit POS",
108+
comment: "Button text to exit Point of Sale when there's a critical error"
109+
)
110+
}
111+
90112
#Preview {
91113
POSListErrorView(error: .errorCouponsDisabled, onAction: {})
92114
}

Modules/Sources/PointOfSale/ViewHelpers/PointOfSaleDashboardViewHelper.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ struct PointOfSaleDashboardViewHelper {
3636
extension PointOfSaleDashboardView.ViewState {
3737
var showsFloatingControl: Bool {
3838
switch self {
39-
case .content, .error, .unsupportedWidth:
39+
case .content, .unsupportedWidth:
4040
return true
41+
case .error(let error):
42+
// Hide floating controls for initial catalog sync errors
43+
// TODO: WOOMOB-1692 remove specialisation of errors if possible
44+
return error.errorType != .initialCatalogSyncError
4145
case .loading, .ineligible:
4246
return false
4347
}

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,37 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
9898
throw POSCatalogSyncError.shouldNotSync
9999
}
100100

101-
if case .syncStarted = fullSyncStateModel.state[siteID] {
101+
switch fullSyncStateModel.state[siteID] {
102+
case .syncStarted, .initialSyncStarted:
102103
DDLogInfo("⚠️ POSCatalogSyncCoordinator: Sync already in progress for site \(siteID)")
103104
throw POSCatalogSyncError.syncAlreadyInProgress(siteID: siteID)
105+
default:
106+
break
104107
}
105108

106-
await emitSyncState(.syncStarted(siteID: siteID, isInitialSync: lastFullSyncDate(for: siteID) == nil))
109+
let isFirstSync = await lastFullSyncDate(for: siteID) == nil
110+
111+
emitSyncState(isFirstSync ? .initialSyncStarted(siteID: siteID) : .syncStarted(siteID: siteID))
107112

108113
DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")
109114

110115
do {
111116
_ = try await fullSyncService.startFullSync(for: siteID, regenerateCatalog: regenerateCatalog)
112117
emitSyncState(.syncCompleted(siteID: siteID))
113118
} catch AFError.explicitlyCancelled, is CancellationError {
114-
emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
119+
if isFirstSync {
120+
emitSyncState(.initialSyncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
121+
} else {
122+
emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
123+
}
115124
throw POSCatalogSyncError.requestCancelled
116125
} catch {
117126
DDLogError("⛔️ POSCatalogSyncCoordinator failed to complete sync for site \(siteID): \(error)")
118-
emitSyncState(.syncFailed(siteID: siteID, error: error))
127+
if isFirstSync {
128+
emitSyncState(.initialSyncFailed(siteID: siteID, error: error))
129+
} else {
130+
emitSyncState(.syncFailed(siteID: siteID, error: error))
131+
}
119132
throw error
120133
}
121134

@@ -303,7 +316,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
303316
private extension POSCatalogSyncCoordinator {
304317
func emitSyncState(_ state: POSCatalogSyncState) {
305318
let siteID: Int64 = switch state {
306-
case .syncStarted(let id, _), .syncCompleted(let id), .syncFailed(let id, _), .syncNeverDone(let id):
319+
case .initialSyncStarted(let id),
320+
.syncStarted(let id),
321+
.syncCompleted(let id),
322+
.initialSyncFailed(let id, _),
323+
.syncFailed(let id, _),
324+
.syncNeverDone(let id):
307325
id
308326
}
309327

@@ -320,21 +338,23 @@ public class POSCatalogSyncStateModel {
320338

321339

322340
public enum POSCatalogSyncState: Equatable {
323-
case syncStarted(siteID: Int64, isInitialSync: Bool)
341+
case initialSyncStarted(siteID: Int64)
342+
case syncStarted(siteID: Int64)
324343
case syncCompleted(siteID: Int64)
344+
case initialSyncFailed(siteID: Int64, error: Error)
325345
case syncFailed(siteID: Int64, error: Error)
326346
case syncNeverDone(siteID: Int64)
327347

328348
public static func == (lhs: POSCatalogSyncState, rhs: POSCatalogSyncState) -> Bool {
329349
switch (lhs, rhs) {
330-
case (.syncStarted(let lhsSiteID, let lhsInitial), .syncStarted(let rhsSiteID, let rhsInitial)):
331-
return lhsSiteID == rhsSiteID && lhsInitial == rhsInitial
332-
case (.syncCompleted(let lhsSiteID), .syncCompleted(let rhsSiteID)):
350+
case (.initialSyncStarted(let lhsSiteID), .initialSyncStarted(let rhsSiteID)),
351+
(.syncStarted(let lhsSiteID), .syncStarted(let rhsSiteID)),
352+
(.syncCompleted(let lhsSiteID), .syncCompleted(let rhsSiteID)),
353+
(.syncNeverDone(let lhsSiteID), .syncNeverDone(let rhsSiteID)):
333354
return lhsSiteID == rhsSiteID
334-
case (.syncFailed(let lhsSiteID, let lhsError), .syncFailed(let rhsSiteID, let rhsError)):
355+
case (.initialSyncFailed(let lhsSiteID, let lhsError), .initialSyncFailed(let rhsSiteID, let rhsError)),
356+
(.syncFailed(let lhsSiteID, let lhsError), .syncFailed(let rhsSiteID, let rhsError)):
335357
return lhsSiteID == rhsSiteID && lhsError.localizedDescription == rhsError.localizedDescription
336-
case (.syncNeverDone(let lhsSiteID), .syncNeverDone(let rhsSiteID)):
337-
return lhsSiteID == rhsSiteID
338358
default:
339359
return false
340360
}

0 commit comments

Comments
 (0)