From f95394432a2be5e765207ce3136768a722925d6f Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 15:26:25 +0000 Subject: [PATCH 1/6] Show errors when refreshing catalog from settings --- .../Models/PointOfSaleErrorState.swift | 17 ++++++++++++++++- .../POSSettingsLocalCatalogDetailView.swift | 8 ++++++++ .../POSSettingsLocalCatalogViewModel.swift | 5 ++++- ...OSSettingsLocalCatalogViewModelTests.swift | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift index c6c43bbb402..91c7d42d8df 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift @@ -1,9 +1,10 @@ import Foundation import enum Alamofire.AFError -struct PointOfSaleErrorState: Equatable { +struct PointOfSaleErrorState: Equatable, Identifiable { enum ErrorType: Equatable { case initialCatalogSyncError + case refreshCatalogSyncError case productsLoadError case variationsLoadError case productsNextPageError @@ -16,6 +17,7 @@ struct PointOfSaleErrorState: Equatable { case ordersNextPageError } + let id = UUID() let errorType: ErrorType let title: String let subtitle: String @@ -117,6 +119,14 @@ struct PointOfSaleErrorState: Equatable { buttonText: Constants.retryButtonTitle) } + static func errorOnRefreshingCatalog(error: Error? = nil) -> Self { + PointOfSaleErrorState( + errorType: .refreshCatalogSyncError, + title: Constants.failedToRefreshCatalogTitle, + subtitle: subtitle(for: error), + buttonText: Constants.retryButtonTitle) + } + private static func subtitle(for error: Error?) -> String { if let error, error.isConnectivityError { return Constants.connectivityErrorSubtitle @@ -141,6 +151,11 @@ struct PointOfSaleErrorState: Equatable { value: "Unable to sync catalog", comment: "Title appearing on the item list screen when there's an error syncing the catalog for the first time." ) + static let failedToRefreshCatalogTitle = NSLocalizedString( + "pos.catalog.refreshFailedTitle", + value: "Unable to refresh catalog", + comment: "Title appearing in a modal when there's an error refreshing the catalog." + ) static let loadingCouponsErrorTitle = NSLocalizedString( "pos.itemList.loadingCouponsErrorTitle.2", value: "Unable to load coupons", diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift index 2eb15ba3e8d..0ce7e0ed2c4 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift @@ -8,6 +8,7 @@ struct POSSettingsLocalCatalogDetailView: View { } var body: some View { + @Bindable var viewModel = viewModel NavigationStack { VStack(spacing: POSSpacing.none) { POSPageHeaderView(title: Localization.localCatalogTitle) @@ -28,6 +29,13 @@ struct POSSettingsLocalCatalogDetailView: View { .task { await viewModel.loadCatalogData() } + .posModal(item: $viewModel.catalogRefreshError) { errorState in + POSListErrorView(error: errorState, onAction: { + Task { + await viewModel.refreshCatalog() + } + }) + } } } diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift index c4e4be965c0..b1c88453073 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift @@ -12,6 +12,8 @@ final class POSSettingsLocalCatalogViewModel { private(set) var isLoading: Bool = false private(set) var isRefreshingCatalog: Bool = false + var catalogRefreshError: PointOfSaleErrorState? = nil + private let siteID: Int64 private let catalogSettingsService: POSCatalogSettingsServiceProtocol private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol @@ -78,15 +80,16 @@ final class POSSettingsLocalCatalogViewModel { @MainActor func refreshCatalog() async { isRefreshingCatalog = true + catalogRefreshError = nil do { try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true) - // Sync completed synchronously - update UI isRefreshingCatalog = false await loadCatalogData() } catch { DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)") isRefreshingCatalog = false + catalogRefreshError = PointOfSaleErrorState.errorOnRefreshingCatalog(error: error) } } diff --git a/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift b/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift index d575fd6eb44..36c23ab75ef 100644 --- a/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift @@ -32,6 +32,7 @@ struct POSSettingsLocalCatalogViewModelTests { #expect(sut.lastIncrementalSyncDate == "") #expect(sut.isLoading == false) #expect(sut.isRefreshingCatalog == false) + #expect(sut.catalogRefreshError == nil) } // MARK: - `loadCatalogData` Tests @@ -175,6 +176,24 @@ struct POSSettingsLocalCatalogViewModelTests { #expect(sut.isRefreshingCatalog == false) } + @Test func refreshCatalog_sets_refresh_error_state_when_sync_fails() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 50, + variationCount: 25, + lastFullSyncDate: nil, + lastIncrementalSyncDate: nil + )) + catalogSyncCoordinator.performFullSyncResult = .failure(MockError.syncError) + + // When + await sut.refreshCatalog() + + // Then + let catalogRefreshError = try #require(sut.catalogRefreshError) + #expect(catalogRefreshError.errorType == .refreshCatalogSyncError) + } + // MARK: - Test Concurrent Operations @Test func concurrent_loadCatalogData_and_refreshCatalog_operations_work_correctly() async throws { From 86bb4e6749bca0eca13921c68d7146f9f04378b1 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 15:05:02 +0000 Subject: [PATCH 2/6] Update error spacing to match designs --- .../UI States/PointOfSaleEmptyErrorStateViewLayout.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleEmptyErrorStateViewLayout.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleEmptyErrorStateViewLayout.swift index 5f698e1bad7..b5cf92bebf8 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleEmptyErrorStateViewLayout.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleEmptyErrorStateViewLayout.swift @@ -2,7 +2,7 @@ import Foundation enum PointOfSaleEmptyErrorStateViewLayout { static let imageAndTextSpacing: CGFloat = POSSpacing.medium - static let textAndButtonSpacing: CGFloat = POSSpacing.large + static let textAndButtonSpacing: CGFloat = POSSpacing.xxLarge static let textSpacing: CGFloat = POSSpacing.small static let buttonSpacing: CGFloat = POSSpacing.medium } From 023770e56ce943df1032145c7b3f0bb09e89bedc Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 15:05:20 +0000 Subject: [PATCH 3/6] Refactor modalCloseButton for reusability --- .../Reusable Views/POSModalCloseButton.swift | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSModalCloseButton.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSModalCloseButton.swift index 13aae6de8b6..dbe81381727 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSModalCloseButton.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSModalCloseButton.swift @@ -5,28 +5,37 @@ extension View { action: @escaping (() -> Void), accessibilityLabel: String = POSModalCloseButton.Localization.defaultAccessibilityLabel) -> some View { self.modifier( - POSModalCloseButton( + POSModalCloseButtonModifier( closeAction: action, accessibilityLabel: accessibilityLabel) ) } } -struct POSModalCloseButton: ViewModifier { +struct POSModalCloseButton: View { + let accessibilityLabel: String + let closeAction: () -> Void + + var body: some View { + HStack { + Spacer() + Button(action: closeAction, label: { + Text(Image(systemName: "xmark")) + .font(.posButtonSymbolMedium) + }) + .foregroundColor(Color.posOnSurface) + .accessibilityLabel(accessibilityLabel) + } + } +} + +struct POSModalCloseButtonModifier: ViewModifier { let closeAction: () -> Void let accessibilityLabel: String func body(content: Content) -> some View { VStack(spacing: 0) { - HStack { - Spacer() - Button(action: closeAction, label: { - Text(Image(systemName: "xmark")) - .font(.posButtonSymbolMedium) - }) - .foregroundColor(Color.posOnSurface) - .accessibilityLabel(accessibilityLabel) - } + POSModalCloseButton(accessibilityLabel: accessibilityLabel, closeAction: closeAction) Spacer() From 27cb6f35649a4070845384416a5532aa20453005 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 15:06:04 +0000 Subject: [PATCH 4/6] Extract POSErrorView for reuse in modals --- .../PointOfSaleDashboardView.swift | 4 + .../Reusable Views/POSErrorView.swift | 128 ++++++++++++++++++ .../Reusable Views/POSListErrorView.swift | 102 +++----------- 3 files changed, 150 insertions(+), 84 deletions(-) create mode 100644 Modules/Sources/PointOfSale/Presentation/Reusable Views/POSErrorView.swift diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift index ec003f7db46..af79f83c8a2 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift @@ -73,6 +73,10 @@ struct PointOfSaleDashboardView: View { .frame(maxWidth: .infinity) case .error(let error): PointOfSaleItemListFullscreenErrorView(error: error, onAction: { + if error.errorType == .initialCatalogSyncError { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.splashScreenRetryTapped()) + } + Task { switch viewStateCoordinator.selectedItemListType { case .products(search: false): diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSErrorView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSErrorView.swift new file mode 100644 index 00000000000..4766b22e30d --- /dev/null +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSErrorView.swift @@ -0,0 +1,128 @@ +import SwiftUI +import WooFoundation + +struct POSErrorView: View { + @Environment(\.keyboardObserver) private var keyboard + + let viewModel: POSErrorViewModel + @Binding var buttonWidth: CGFloat? + + init(viewModel: POSErrorViewModel, buttonWidth: Binding? = nil) { + self.viewModel = viewModel + if let buttonWidth { + self._buttonWidth = buttonWidth + } else { + self._buttonWidth = Binding( + get: { nil }, + set: { _ in }) + } + } + + var body: some View { + VStack(alignment: .center, spacing: POSSpacing.none) { + if !keyboard.isFullSizeKeyboardVisible { + if let image = viewModel.imageAsset { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 88, height: 88) + .foregroundColor(.posOnSurfaceVariantHighest) + } else { + POSErrorXMark(size: .large) + } + Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.imageAndTextSpacing) + } + + Text(viewModel.title) + .accessibilityAddTraits(.isHeader) + .foregroundStyle(Color.posOnSurface) + .font(.posHeadingBold) + + Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.textSpacing) + + Text(viewModel.subtitle) + .foregroundStyle(Color.posOnSurface) + .font(.posBodyLargeRegular()) + .padding([.leading, .trailing]) + + if viewModel.primaryButton != nil || viewModel.secondaryButton != nil { + Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.textAndButtonSpacing) + VStack(spacing: POSSpacing.medium) { + if let primaryButtonViewModel = viewModel.primaryButton { + POSErrorButton(viewModel: primaryButtonViewModel) + } + + if let secondaryButtonViewModel = viewModel.secondaryButton { + POSErrorButton(viewModel: secondaryButtonViewModel) + } + } + .frame(width: buttonWidth) + } + } + .multilineTextAlignment(.center) + .dynamicTypeSize(.. Void + + init(title: String, buttonStyle: any ButtonStyle, action: @escaping () -> Void) { + self.title = title + self.buttonStyle = AnyButtonStyle(buttonStyle) + self.action = action + } +} + +struct AnyButtonStyle: ButtonStyle { + private let _makeBody: (Configuration) -> AnyView + + init(_ style: S) { + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift index ef21a89df7b..f3bad324341 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift @@ -8,85 +8,38 @@ struct POSListErrorView: View { @Environment(\.posAnalytics) private var analytics private let error: PointOfSaleErrorState - private let viewModel: POSListErrorViewModel - private let onAction: (() -> Void)? - private let onExit: (() -> Void)? - - @State private var viewWidth: CGFloat = 0 + @State private var buttonWidth: CGFloat? = nil + private let viewModel: POSErrorViewModel @Environment(\.keyboardObserver) private var keyboard init(error: PointOfSaleErrorState, onAction: (() -> Void)? = nil, onExit: (() -> Void)? = nil) { self.error = error - self.viewModel = POSListErrorViewModel(error: error) - self.onAction = onAction - self.onExit = onExit + let actionButton: POSErrorButtonViewModel? = { + guard let onAction else { return nil } + return POSErrorButtonViewModel(title: error.buttonText, + buttonStyle: (POSFilledButtonStyle(size: .normal)), + action: onAction) + }() + let exitButton: POSErrorButtonViewModel? = { + guard let onExit else { return nil } + return POSErrorButtonViewModel(title: Localization.exitButtonText, + buttonStyle: POSOutlinedButtonStyle(size: .normal), + action: onExit) + }() + + self.viewModel = POSErrorViewModel(error: error, primaryButton: actionButton, secondaryButton: exitButton) } var body: some View { ScrollableVStack { Spacer() - VStack(alignment: .center, spacing: POSSpacing.none) { - if !keyboard.isFullSizeKeyboardVisible { - if let image = viewModel.imageAsset { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 88, height: 88) - .foregroundColor(.posOnSurfaceVariantHighest) - } else { - POSErrorXMark(size: .large) - } - Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.imageAndTextSpacing) - } - - Text(viewModel.title) - .accessibilityAddTraits(.isHeader) - .foregroundStyle(Color.posOnSurface) - .multilineTextAlignment(.center) - .font(.posHeadingBold) - - Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.textSpacing) - - Text(viewModel.subtitle) - .foregroundStyle(Color.posOnSurface) - .font(.posBodyLargeRegular()) - .multilineTextAlignment(.center) - .padding([.leading, .trailing]) - - if let onAction { - Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.textAndButtonSpacing) - Button(action: { - // Track retry tapped for splash screen errors (initial catalog sync) - if error.errorType == .initialCatalogSyncError { - analytics.track(event: WooAnalyticsEvent.LocalCatalog.splashScreenRetryTapped()) - } - onAction() - }, label: { - Text(viewModel.buttonText) - }) - .buttonStyle(POSFilledButtonStyle(size: .normal)) - .frame(width: viewWidth / 2) - .padding([.leading, .trailing]) - } - - if let onExit { - Spacer().frame(height: POSSpacing.medium) - Button(action: { - onExit() - }, label: { - Text(Localization.exitButtonText) - }) - .buttonStyle(POSOutlinedButtonStyle(size: .normal)) - .frame(width: viewWidth / 2) - .padding([.leading, .trailing]) - } - } + POSErrorView(viewModel: viewModel, buttonWidth: $buttonWidth) Spacer() } .padding(.bottom, !keyboard.isFullSizeKeyboardVisible ? floatingControlAreaSize.height : 0) .measureWidth { width in - viewWidth = width + buttonWidth = width / 2 } .onAppear { // Track error shown for splash screen errors (initial catalog sync) @@ -97,25 +50,6 @@ struct POSListErrorView: View { } } -struct POSListErrorViewModel { - let title: String - let subtitle: String - let buttonText: String - let imageAsset: Image? - - init(error: PointOfSaleErrorState) { - self.title = error.title - self.subtitle = error.subtitle - self.buttonText = error.buttonText - switch error.errorType { - case .couponsDisabled: - self.imageAsset = SharedImageAsset.coupons.decorativeImage - default: - self.imageAsset = nil - } - } -} - private enum Localization { static let exitButtonText = NSLocalizedString( "pos.listError.exitButton", From 7533b83290153e9646f32e72bb8b297ac1c1db24 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 15:15:51 +0000 Subject: [PATCH 5/6] Use POSErrorView for local catalog refresh errors --- .../POSSettingsLocalCatalogDetailView.swift | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift index 0ce7e0ed2c4..e83993538c7 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift @@ -30,11 +30,7 @@ struct POSSettingsLocalCatalogDetailView: View { await viewModel.loadCatalogData() } .posModal(item: $viewModel.catalogRefreshError) { errorState in - POSListErrorView(error: errorState, onAction: { - Task { - await viewModel.refreshCatalog() - } - }) + errorView(errorState: errorState) } } } @@ -99,6 +95,34 @@ private extension POSSettingsLocalCatalogDetailView { } } } + + @ViewBuilder + func errorView(errorState: PointOfSaleErrorState) -> some View { + VStack(spacing: POSSpacing.xxLarge) { + POSModalCloseButton(accessibilityLabel: Localization.errorCancelButtonTitle) { + viewModel.catalogRefreshError = nil + } + + POSErrorView(viewModel: errorViewModel(errorState: errorState)) + } + .padding(POSPadding.xLarge) + .frame(maxWidth: Constants.errorModalMaxWidth) + } + + func errorViewModel(errorState: PointOfSaleErrorState) -> POSErrorViewModel { + let retryButton = POSErrorButtonViewModel(title: Localization.errorRetryButtonTitle, + buttonStyle: POSFilledButtonStyle(size: .normal)) { + Task { + await viewModel.refreshCatalog() + } + } + let cancelButton = POSErrorButtonViewModel(title: Localization.errorCancelButtonTitle, + buttonStyle: POSOutlinedButtonStyle(size: .normal)) { + viewModel.catalogRefreshError = nil + } + return POSErrorViewModel(error: errorState, primaryButton: retryButton, secondaryButton: cancelButton) + } + } private extension POSSettingsLocalCatalogDetailView { @@ -160,6 +184,22 @@ private extension POSSettingsLocalCatalogDetailView { value: "Update catalog", comment: "Button text for updating the catalog manually." ) + + static let errorRetryButtonTitle = NSLocalizedString( + "posSettingsLocalCatalogDetailView.catalogRefresh.error.retryButton.title", + value: "Retry", + comment: "Button text for retrying a refresh after it fails" + ) + + static let errorCancelButtonTitle = NSLocalizedString( + "posSettingsLocalCatalogDetailView.catalogRefresh.error.cancelButton.title", + value: "Cancel", + comment: "Button text for closing an error after a refresh fails" + ) + } + + enum Constants { + static let errorModalMaxWidth: CGFloat = 832 } } From a6b295f3efd659e11cee08014bf775ce8fbf4da8 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 17:06:32 +0000 Subject: [PATCH 6/6] Keep the errorState free of IDs --- .../PointOfSale/Models/PointOfSaleErrorState.swift | 3 +-- .../Settings/POSSettingsLocalCatalogDetailView.swift | 4 ++-- .../Settings/POSSettingsLocalCatalogViewModel.swift | 9 +++++++-- .../Settings/POSSettingsLocalCatalogViewModelTests.swift | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift index 91c7d42d8df..0da36d42985 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleErrorState.swift @@ -1,7 +1,7 @@ import Foundation import enum Alamofire.AFError -struct PointOfSaleErrorState: Equatable, Identifiable { +struct PointOfSaleErrorState: Equatable { enum ErrorType: Equatable { case initialCatalogSyncError case refreshCatalogSyncError @@ -17,7 +17,6 @@ struct PointOfSaleErrorState: Equatable, Identifiable { case ordersNextPageError } - let id = UUID() let errorType: ErrorType let title: String let subtitle: String diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift index e83993538c7..1871bef32ad 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift @@ -29,8 +29,8 @@ struct POSSettingsLocalCatalogDetailView: View { .task { await viewModel.loadCatalogData() } - .posModal(item: $viewModel.catalogRefreshError) { errorState in - errorView(errorState: errorState) + .posModal(item: $viewModel.catalogRefreshError) { error in + errorView(errorState: error.errorState) } } } diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift index b1c88453073..e4ced8b21b1 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift @@ -12,7 +12,7 @@ final class POSSettingsLocalCatalogViewModel { private(set) var isLoading: Bool = false private(set) var isRefreshingCatalog: Bool = false - var catalogRefreshError: PointOfSaleErrorState? = nil + var catalogRefreshError: POSIdentifiableErrorState? = nil private let siteID: Int64 private let catalogSettingsService: POSCatalogSettingsServiceProtocol @@ -89,7 +89,7 @@ final class POSSettingsLocalCatalogViewModel { } catch { DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)") isRefreshingCatalog = false - catalogRefreshError = PointOfSaleErrorState.errorOnRefreshingCatalog(error: error) + catalogRefreshError = POSIdentifiableErrorState(errorState: .errorOnRefreshingCatalog(error: error)) } } @@ -177,3 +177,8 @@ private extension POSSettingsLocalCatalogViewModel { ) } } + +struct POSIdentifiableErrorState: Identifiable, Equatable { + let errorState: PointOfSaleErrorState + let id = UUID() +} diff --git a/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift b/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift index 36c23ab75ef..82856715d07 100644 --- a/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift @@ -191,7 +191,7 @@ struct POSSettingsLocalCatalogViewModelTests { // Then let catalogRefreshError = try #require(sut.catalogRefreshError) - #expect(catalogRefreshError.errorType == .refreshCatalogSyncError) + #expect(catalogRefreshError.errorState.errorType == .refreshCatalogSyncError) } // MARK: - Test Concurrent Operations