From 9c6f0e4ee094dd277df4e4578da940b4224b2461 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 21 Apr 2025 17:20:27 +0100 Subject: [PATCH 1/4] Initial attempt at a favorite product implementation --- .../Networking/Remote/ProductsRemote.swift | 17 ++++ .../PointOfSaleCouponsController.swift | 4 + .../PointOfSaleItemsController.swift | 17 ++++ .../Models/PointOfSaleAggregateModel.swift | 4 + .../Presentation/Item Selector/ItemList.swift | 85 +++++++++++++------ .../POS/Presentation/ItemListView.swift | 66 +++++++++----- .../PointOfSaleEntryPointView.swift | 11 ++- .../POSFavouriteProductsService.swift | 70 +++++++++++++++ .../Classes/POS/Utils/PreviewHelpers.swift | 25 +++++- .../ViewRelated/Hub Menu/HubMenu.swift | 3 +- .../WooCommerce.xcodeproj/project.pbxproj | 12 +++ .../PointOfSaleItemFetchStrategyFactory.swift | 7 ++ ...ntOfSalePurchasableItemFetchStrategy.swift | 29 +++++++ 13 files changed, 296 insertions(+), 54 deletions(-) create mode 100644 WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift diff --git a/Networking/Networking/Remote/ProductsRemote.swift b/Networking/Networking/Remote/ProductsRemote.swift index a0e0041f900..d14496af93b 100644 --- a/Networking/Networking/Remote/ProductsRemote.swift +++ b/Networking/Networking/Remote/ProductsRemote.swift @@ -217,6 +217,23 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { parameters: parameters) } + public func loadFavoriteProductsForPointOfSale(for siteID: Int64, + productIDs: [Int64], + productTypes: [ProductType] = [.simple], + pageNumber: Int = 1) async throws -> PagedItems { + var parameters = pointOfSaleProductFetchParameters( + pageNumber: pageNumber, + productTypes: productTypes) + + // Add the include parameter for favorite product IDs + parameters[ParameterKey.include] = productIDs.map { String($0) }.joined(separator: ",") + + return try await makePagedPointOfSaleProductsRequest( + for: siteID, + pageNumber: pageNumber, + parameters: parameters) + } + private func pointOfSaleProductFetchParameters(pageNumber: Int, productTypes: [ProductType]) -> [String: String] { [ diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleCouponsController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleCouponsController.swift index 83c10c34c01..2bd667087e6 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleCouponsController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleCouponsController.swift @@ -13,6 +13,10 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleItemsControllerProtoco @available(iOS 17.0, *) @Observable final class PointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol { + func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async {} + + func clearFavoritesFilter(baseItem: ItemListBaseItem) async {} + var itemsViewState: ItemsViewState = ItemsViewState(containerState: .content, itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift index 6e2fb54ae66..136aa6b4d74 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift @@ -34,6 +34,9 @@ protocol PointOfSaleItemsControllerProtocol { func refreshItems(base: ItemListBaseItem) async /// Loads the next page of items for a given base item. func loadNextItems(base: ItemListBaseItem) async + + func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async + func clearFavoritesFilter(baseItem: ItemListBaseItem) async } @available(iOS 17.0, *) @@ -82,6 +85,20 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController await loadFirstPage(base: baseItem) } + @MainActor + func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async { + fetchStrategy = itemFetchStrategyFactory.favoritesStrategy(productIDs: productIDs) + setSearchingState(base: baseItem) + await loadFirstPage(base: baseItem) + } + + @MainActor + func clearFavoritesFilter(baseItem: ItemListBaseItem) async { + fetchStrategy = itemFetchStrategyFactory.defaultStrategy + setSearchingState(base: baseItem) + await loadFirstPage(base: baseItem) + } + @MainActor private func loadFirstPage(base: ItemListBaseItem) async { switch base { diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift index 1a4f7d1feeb..ca1a9940dc8 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift @@ -29,6 +29,7 @@ protocol PointOfSaleAggregateModelProtocol { var purchasableItemsController: PointOfSaleItemsControllerProtocol { get } var purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { get } var couponsController: PointOfSaleCouponsControllerProtocol { get } + var favoriteProductsService: POSFavouriteProductsServiceProtocol { get } var cart: Cart { get } func addToCart(_ item: POSItem) @@ -65,6 +66,7 @@ protocol PointOfSaleAggregateModelProtocol { let purchasableItemsController: PointOfSaleItemsControllerProtocol let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol let couponsController: PointOfSaleCouponsControllerProtocol + let favoriteProductsService: POSFavouriteProductsServiceProtocol private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol @@ -85,6 +87,7 @@ protocol PointOfSaleAggregateModelProtocol { analytics: Analytics = ServiceLocator.analytics, collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking, searchHistoryService: POSSearchHistoryProviding, + favoriteProductsService: POSFavouriteProductsServiceProtocol, paymentState: PointOfSalePaymentState = .card(.idle)) { self.purchasableItemsController = itemsController self.purchasableItemsSearchController = purchasableItemsSearchController @@ -94,6 +97,7 @@ protocol PointOfSaleAggregateModelProtocol { self.analytics = analytics self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService + self.favoriteProductsService = favoriteProductsService self.paymentState = paymentState publishCardReaderConnectionStatus() publishPaymentMessages() diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift index f75cf32aea1..bbbb377766b 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift @@ -140,39 +140,74 @@ private struct ItemListRow: View { @Environment(PointOfSaleAggregateModel.self) private var posModel let analytics: Analytics = ServiceLocator.analytics + @State private var isFavorite: Bool = false + var body: some View { switch item { case let .simpleProduct(product): - Button(action: { - itemActionHandler.handleTap(item) - }, label: { - SimpleProductCardView(product: product) - }) - case let .variableParentProduct(parentProduct): - if #available(iOS 18.0, *) { - NavigationLink(value: item) { - ParentProductCardView(name: parentProduct.name, - imageSource: parentProduct.productImageSource, - detailText: Localization.variationsAvailable) + HStack { + Button(action: { + itemActionHandler.handleTap(item) + }, label: { + SimpleProductCardView(product: product) + }) + + Button(action: { + if isFavorite { + posModel.favoriteProductsService.removeFromFavorite(productID: product.productID) + } else { + posModel.favoriteProductsService.markAsFavorite(productID: product.productID) + } + isFavorite.toggle() + }) { + Image(systemName: isFavorite ? "star.fill" : "star") + .foregroundColor(.posOnSurface) + .font(.posButtonSymbolLarge) + .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) } - } else { - // Use a button to trigger navigation programmatically on iOS 17. + .padding(.trailing, POSPadding.small) + } + .task { + isFavorite = await posModel.favoriteProductsService.isFavorite(productID: product.productID) + } - // We should drop this when we leave iOS 17.0 behind, but due to memory leaks caused by NavigationStack. - // we still have to use the NavigationView approach here. - // When we remove it, itemsStack will no longer be a dependency of ItemList + case let .variableParentProduct(parentProduct): + HStack { + if #available(iOS 18.0, *) { + NavigationLink(value: item) { + ParentProductCardView(name: parentProduct.name, + imageSource: parentProduct.productImageSource, + detailText: Localization.variationsAvailable) + } + } else { + Button(action: { + activeNavigationItem = item + }, label: { + ParentProductCardView(name: parentProduct.name, + imageSource: parentProduct.productImageSource, + detailText: Localization.variationsAvailable) + }) + } - // Note that we don't use Navigation Link as this row can be redrawn if the dynamic type size - // is changed enough to push it offscreen. When that happens while viewing a child list, - // the navigation gets cancelled and the user is sent back to the root. Button(action: { - activeNavigationItem = item - }, label: { - ParentProductCardView(name: parentProduct.name, - imageSource: parentProduct.productImageSource, - detailText: Localization.variationsAvailable) - }) + if isFavorite { + posModel.favoriteProductsService.removeFromFavorite(productID: parentProduct.productID) + } else { + posModel.favoriteProductsService.markAsFavorite(productID: parentProduct.productID) + } + isFavorite.toggle() + }) { + Image(systemName: isFavorite ? "star.fill" : "star") + .foregroundColor(.posOnSurface) + .font(.posButtonSymbolLarge) + .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) + } + .padding(.trailing, POSPadding.small) + } + .task { + isFavorite = await posModel.favoriteProductsService.isFavorite(productID: parentProduct.productID) } + case let .variation(variation): Button(action: { itemActionHandler.handleTap(item) diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift index e9e5a77f4b2..c24d3689431 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift @@ -9,6 +9,7 @@ struct ItemListView: View { @Environment(PointOfSaleAggregateModel.self) private var posModel @State private var showSimpleProductsModal: Bool = false + @State private var showFavoritesOnly: Bool = false @Binding var selectedItemListType: ItemListType @Binding var searchTerm: String @@ -168,34 +169,53 @@ private extension ItemListView { } } - POSPageHeaderActionButton(systemName: "magnifyingglass") { - selectedItemListType = .products(search: true) - isSearchFieldFocused = true + if case .products = selectedItemListType, !shouldShowSearchField { + POSPageHeaderActionButton(systemName: "magnifyingglass") { + selectedItemListType = .products(search: true) + isSearchFieldFocused = true + } + .renderedIf(!shouldShowSearchField) + .transition(.opacity.combined(with: .scale)) + + Button { + showFavoritesOnly.toggle() + Task { @MainActor in + if showFavoritesOnly { + let favoriteProductIDs = await posModel.favoriteProductsService.favoriteProductIDs() + await posModel.purchasableItemsController.setFavoritesFilter(productIDs: favoriteProductIDs, baseItem: .root) + } else { + await posModel.purchasableItemsController.clearFavoritesFilter(baseItem: .root) + } + } + } label: { + Image(systemName: showFavoritesOnly ? "star.fill" : "star") + .foregroundColor(.posOnSurface) + .font(.posButtonSymbolLarge) + .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) + } } - .renderedIf(!shouldShowSearchField) - .transition(.opacity.combined(with: .scale)) - } - if shouldShowCoupons { - POSPageHeaderActionButton(systemName: "plus") { - ServiceLocator.analytics.track(.pointOfSaleCouponsCreateTapped) - showCouponCreationModal = true + if shouldShowCoupons { + POSPageHeaderActionButton(systemName: "plus") { + ServiceLocator.analytics.track(.pointOfSaleCouponsCreateTapped) + showCouponCreationModal = true + } + .renderedIf(isAddingCouponAllowed) + .transition(.opacity.combined(with: .scale)) } - .renderedIf(isAddingCouponAllowed) + + Button(action: { + ServiceLocator.analytics.track(.pointOfSaleSimpleProductsExplanationDialogShown) + showSimpleProductsModal = true + }, label: { + Text(Image(systemName: "info.circle")) + .font(.posButtonSymbolLarge) + .foregroundStyle(Color.posOnSurface) + .padding(Constants.infoIconInset) + }) + .renderedIf(!shouldShowHeaderBanner && !shouldShowCoupons) .transition(.opacity.combined(with: .scale)) } - - Button(action: { - ServiceLocator.analytics.track(.pointOfSaleSimpleProductsExplanationDialogShown) - showSimpleProductsModal = true - }, label: { - Text(Image(systemName: "info.circle")) - .font(.posButtonSymbolLarge) - .foregroundStyle(Color.posOnSurface) - .padding(Constants.infoIconInset) - }) - .renderedIf(!shouldShowHeaderBanner && !shouldShowCoupons) - .transition(.opacity.combined(with: .scale)) } }) if !dynamicTypeSize.isAccessibilitySize, shouldShowHeaderBanner { diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index d6e9b5fd16c..ea69e5b524e 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -15,6 +15,7 @@ struct PointOfSaleEntryPointView: View { private let orderController: PointOfSaleOrderControllerProtocol private let collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking private let searchHistoryService: POSSearchHistoryProviding + private let favoriteProductsService: POSFavouriteProductsServiceProtocol init(itemsController: PointOfSaleItemsControllerProtocol, purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol, @@ -23,7 +24,8 @@ struct PointOfSaleEntryPointView: View { cardPresentPaymentService: CardPresentPaymentFacade, orderController: PointOfSaleOrderControllerProtocol, collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking, - searchHistoryService: POSSearchHistoryProviding) { + searchHistoryService: POSSearchHistoryProviding, + favoriteProductsService: POSFavouriteProductsServiceProtocol) { self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange self.itemsController = itemsController @@ -33,6 +35,7 @@ struct PointOfSaleEntryPointView: View { self.orderController = orderController self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService + self.favoriteProductsService = favoriteProductsService } var body: some View { @@ -55,7 +58,8 @@ struct PointOfSaleEntryPointView: View { cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker, - searchHistoryService: searchHistoryService) + searchHistoryService: searchHistoryService, + favoriteProductsService: favoriteProductsService) } .environmentObject(posModalManager) .onAppear { @@ -79,6 +83,7 @@ struct PointOfSaleEntryPointView: View { cardPresentPaymentService: CardPresentPaymentPreviewService(), orderController: PointOfSalePreviewOrderController(), collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalytics(), - searchHistoryService: PointOfSalePreviewHistoryService()) + searchHistoryService: PointOfSalePreviewHistoryService(), + favoriteProductsService: PointOfSalePreviewFavoriteProductService()) } #endif diff --git a/WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift b/WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift new file mode 100644 index 00000000000..2cfd52f5f3b --- /dev/null +++ b/WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift @@ -0,0 +1,70 @@ +import Foundation +import Yosemite +import Experiments + +/// Service to handle favorite products functionality for POS +/// +protocol POSFavouriteProductsServiceProtocol { + /// Returns true if the product is marked as favorite + @MainActor + func isFavorite(productID: Int64) async -> Bool + + /// Returns all favorite product IDs for the current site + @MainActor + func favoriteProductIDs() async -> [Int64] + + /// Marks a product as favorite + @MainActor + func markAsFavorite(productID: Int64) + + /// Removes a product from favorites + @MainActor + func removeFromFavorite(productID: Int64) +} + +final class POSFavouriteProductsService: POSFavouriteProductsServiceProtocol { + private let siteID: Int64 + private let stores: StoresManager + private let featureFlagService: FeatureFlagService + + init(siteID: Int64, + stores: StoresManager = ServiceLocator.stores, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { + self.siteID = siteID + self.stores = stores + self.featureFlagService = featureFlagService + } + + @MainActor + func isFavorite(productID: Int64) async -> Bool { + return await withCheckedContinuation { @MainActor continuation in + stores.dispatch(AppSettingsAction.loadFavoriteProductIDs(siteID: siteID, onCompletion: { savedFavProductIDs in + continuation.resume(returning: savedFavProductIDs.contains(where: { $0 == productID })) + })) + } + } + + @MainActor + func favoriteProductIDs() async -> [Int64] { + guard featureFlagService.isFeatureFlagEnabled(.favoriteProducts) else { + return [] + } + return await withCheckedContinuation { @MainActor continuation in + stores.dispatch(AppSettingsAction.loadFavoriteProductIDs(siteID: siteID, onCompletion: { savedFavProductIDs in + continuation.resume(returning: savedFavProductIDs) + })) + } + } + + @MainActor + func markAsFavorite(productID: Int64) { + let action = AppSettingsAction.setProductIDAsFavorite(productID: productID, siteID: siteID) + stores.dispatch(action) + } + + @MainActor + func removeFromFavorite(productID: Int64) { + let action = AppSettingsAction.removeProductIDAsFavorite(productID: productID, siteID: siteID) + stores.dispatch(action) + } +} diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index 175cf74b346..990a5b8b2b3 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -88,6 +88,8 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } func loadNextItems(base: ItemListBaseItem) async { } + func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async { } + func clearFavoritesFilter(baseItem: ItemListBaseItem) async { } } @available(iOS 17.0, *) @@ -121,6 +123,9 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll private func loadInitialChildItems(for parent: POSItem) async { // Set `itemsViewState` instead. } + + func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async { } + func clearFavoritesFilter(baseItem: ItemListBaseItem) async { } } @available(iOS 17.0, *) @@ -140,6 +145,20 @@ final class PointOfSalePreviewHistoryService: POSSearchHistoryProviding { func clearAllSearchHistory() {} } +final class PointOfSalePreviewFavoriteProductService: POSFavouriteProductsServiceProtocol { + func isFavorite(productID: Int64) async -> Bool { + return false + } + + func favoriteProductIDs() async -> [Int64] { + return [] + } + + func markAsFavorite(productID: Int64) {} + + func removeFromFavorite(productID: Int64) {} +} + private var mockItems: [POSItem] { return [ .simpleProduct(POSSimpleProduct(id: UUID(), name: "Product 1", formattedPrice: "$1.00", productID: 1, price: "1.00")), @@ -195,7 +214,8 @@ struct POSPreviewHelpers { cardPresentPaymentService: CardPresentPaymentFacade = CardPresentPaymentPreviewService(), orderController: PointOfSaleOrderControllerProtocol = PointOfSalePreviewOrderController(), collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking = POSCollectOrderPaymentAnalytics(), - searchHistoryService: POSSearchHistoryProviding = PointOfSalePreviewHistoryService() + searchHistoryService: POSSearchHistoryProviding = PointOfSalePreviewHistoryService(), + favoriteProductsService: POSFavouriteProductsServiceProtocol = PointOfSalePreviewFavoriteProductService() ) -> PointOfSaleAggregateModel { return PointOfSaleAggregateModel( itemsController: itemsController, @@ -204,7 +224,8 @@ struct POSPreviewHelpers { cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker, - searchHistoryService: searchHistoryService + searchHistoryService: searchHistoryService, + favoriteProductsService: favoriteProductsService ) } } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift index 47851740d73..710cb2e5ac1 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift @@ -57,7 +57,8 @@ struct HubMenu: View { orderController: PointOfSaleOrderController(orderService: orderService, receiptService: receiptService), collectOrderPaymentAnalyticsTracker: viewModel.collectOrderPaymentAnalyticsTracker, - searchHistoryService: POSSearchHistoryService(siteID: viewModel.siteID)) + searchHistoryService: POSSearchHistoryService(siteID: viewModel.siteID), + favoriteProductsService: POSFavouriteProductsService(siteID: viewModel.siteID)) } else { // TODO: When we have a singleton for the card payment service, this should not be required. Text("Error creating card payment service") diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index eeaa70df45a..b2eeca6890a 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -886,6 +886,7 @@ 209B15672AD85F070094152A /* OperatingSystemVersion+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209B15662AD85F070094152A /* OperatingSystemVersion+Localization.swift */; }; 209B7A682CEB6742003BDEF0 /* PointOfSalePaymentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209B7A672CEB6742003BDEF0 /* PointOfSalePaymentState.swift */; }; 209CA0EE2B50070D0073D1AC /* WooTabContainerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209CA0ED2B50070D0073D1AC /* WooTabContainerController.swift */; }; + 209E9A732DB6ACF90089F3D2 /* POSFavouriteProductsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209E9A712DB6A7A50089F3D2 /* POSFavouriteProductsService.swift */; }; 209EEF902C762ED5007969A4 /* POSModalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209EEF8F2C762ED5007969A4 /* POSModalManager.swift */; }; 20A130EB2C5A27190058022F /* PointOfSaleAssetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A130EA2C5A27190058022F /* PointOfSaleAssetsTests.swift */; }; 20A3AFE12B0F750B0033AF2D /* MockInPersonPaymentsCashOnDeliveryToggleRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A3AFE02B0F750B0033AF2D /* MockInPersonPaymentsCashOnDeliveryToggleRowViewModel.swift */; }; @@ -4147,6 +4148,7 @@ 209B15662AD85F070094152A /* OperatingSystemVersion+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Localization.swift"; sourceTree = ""; }; 209B7A672CEB6742003BDEF0 /* PointOfSalePaymentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSalePaymentState.swift; sourceTree = ""; }; 209CA0ED2B50070D0073D1AC /* WooTabContainerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooTabContainerController.swift; sourceTree = ""; }; + 209E9A712DB6A7A50089F3D2 /* POSFavouriteProductsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFavouriteProductsService.swift; sourceTree = ""; }; 209EEF8F2C762ED5007969A4 /* POSModalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSModalManager.swift; sourceTree = ""; }; 209FC9C42228DA65BD11D65E /* Pods-WordPressAuthenticator.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressAuthenticator.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressAuthenticator/Pods-WordPressAuthenticator.release-alpha.xcconfig"; sourceTree = ""; }; 20A130EA2C5A27190058022F /* PointOfSaleAssetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleAssetsTests.swift; sourceTree = ""; }; @@ -7626,6 +7628,7 @@ 200BA1572CF092150006DC5B /* Controllers */, 026826A12BF59DED0036F959 /* Presentation */, 026826912BF59D7A0036F959 /* ViewHelpers */, + 209E9A722DB6A7A50089F3D2 /* Services */, 02D1D2D82CD3CD710069A93F /* Analytics */, 2004E2C02C076CCA00D62521 /* Card Present Payments */, 026826972BF59D9E0036F959 /* Utils */, @@ -8411,6 +8414,14 @@ path = "Payments Onboarding"; sourceTree = ""; }; + 209E9A722DB6A7A50089F3D2 /* Services */ = { + isa = PBXGroup; + children = ( + 209E9A712DB6A7A50089F3D2 /* POSFavouriteProductsService.swift */, + ); + path = Services; + sourceTree = ""; + }; 20A130E92C5A26F70058022F /* Tools */ = { isa = PBXGroup; children = ( @@ -17098,6 +17109,7 @@ 021AEF9E2407F55C00029D28 /* PHAssetImageLoader.swift in Sources */, DECE13FB27993F6500816ECD /* TitleAndSubtitleAndStatusTableViewCell.swift in Sources */, 200BA1592CF092280006DC5B /* PointOfSaleItemsController.swift in Sources */, + 209E9A732DB6ACF90089F3D2 /* POSFavouriteProductsService.swift in Sources */, DEFC9BE22B2FF62C00138B05 /* WooAnalyticsEvent+Themes.swift in Sources */, EE35AFA32B0491960074E7AC /* SubscriptionTrialViewModel.swift in Sources */, 26BCA0402C35E9A9000BE96C /* BackgroundTaskRefreshDispatcher.swift in Sources */, diff --git a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemFetchStrategyFactory.swift b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemFetchStrategyFactory.swift index 51b14c0dbc7..63a75e7c38c 100644 --- a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemFetchStrategyFactory.swift +++ b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemFetchStrategyFactory.swift @@ -27,4 +27,11 @@ public final class PointOfSaleItemFetchStrategyFactory { productsRemote: productsRemote, variationsRemote: variationsRemote) } + + public func favoritesStrategy(productIDs: [Int64]) -> PointOfSaleFavoritesPurchasableItemFetchStrategy { + PointOfSaleFavoritesPurchasableItemFetchStrategy(siteID: siteID, + productIDs: productIDs, + productsRemote: productsRemote, + variationsRemote: variationsRemote) + } } diff --git a/Yosemite/Yosemite/PointOfSale/PointOfSalePurchasableItemFetchStrategy.swift b/Yosemite/Yosemite/PointOfSale/PointOfSalePurchasableItemFetchStrategy.swift index c27f225b17d..d75f4a373b5 100644 --- a/Yosemite/Yosemite/PointOfSale/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Yosemite/Yosemite/PointOfSale/PointOfSalePurchasableItemFetchStrategy.swift @@ -65,3 +65,32 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa pageNumber: pageNumber) } } + +public struct PointOfSaleFavoritesPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { + private let siteID: Int64 + private let productIDs: [Int64] + private let productsRemote: ProductsRemote + private let variationsRemote: ProductVariationsRemote + + init(siteID: Int64, productIDs: [Int64], productsRemote: ProductsRemote, variationsRemote: ProductVariationsRemote) { + self.siteID = siteID + self.productIDs = productIDs + self.productsRemote = productsRemote + self.variationsRemote = variationsRemote + } + + public func fetchProducts(pageNumber: Int) async throws -> PagedItems { + let productTypes: [ProductType] = [.simple, .variable] + return try await productsRemote.loadFavoriteProductsForPointOfSale(for: siteID, + productIDs: productIDs, + productTypes: productTypes, + pageNumber: pageNumber) + } + + public func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { + try await variationsRemote + .loadVariationsForPointOfSale(for: siteID, + parentProductID: parentProductID, + pageNumber: pageNumber) + } +} From ed363495447b42ebd91716ca076dff355ad41abc Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 21 Apr 2025 18:17:52 +0100 Subject: [PATCH 2/4] Move favouriteProductsService to Yosemite --- .../Models/PointOfSaleAggregateModel.swift | 1 + .../PointOfSaleEntryPointView.swift | 1 + .../POSFavouriteProductsService.swift | 70 ------------------- .../Classes/POS/Utils/PreviewHelpers.swift | 1 + .../WooCommerce.xcodeproj/project.pbxproj | 12 ---- Yosemite/Yosemite.xcodeproj/project.pbxproj | 4 ++ .../POSFavouriteProductsService.swift | 46 ++++++++++++ .../Yosemite/Stores/AppSettingsStore.swift | 21 +----- .../SiteSpecificAppSettingsStoreMethods.swift | 31 ++++++++ ...kSiteSpecificAppSettingsStoreMethods.swift | 37 ++++++++++ ...SpecificAppSettingsStoreMethodsTests.swift | 65 +++++++++++++++++ 11 files changed, 189 insertions(+), 100 deletions(-) delete mode 100644 WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift create mode 100644 Yosemite/Yosemite/PointOfSale/POSFavouriteProductsService.swift diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift index ca1a9940dc8..df45e1c7dea 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift @@ -11,6 +11,7 @@ import enum Yosemite.POSItem import enum Yosemite.SystemStatusAction import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItemType +import protocol Yosemite.POSFavouriteProductsServiceProtocol @available(iOS 17.0, *) protocol PointOfSaleAggregateModelProtocol { diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index ea69e5b524e..d0cd64eabc7 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -1,5 +1,6 @@ import SwiftUI import protocol Yosemite.POSSearchHistoryProviding +import protocol Yosemite.POSFavouriteProductsServiceProtocol @available(iOS 17.0, *) struct PointOfSaleEntryPointView: View { diff --git a/WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift b/WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift deleted file mode 100644 index 2cfd52f5f3b..00000000000 --- a/WooCommerce/Classes/POS/Services/POSFavouriteProductsService.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import Yosemite -import Experiments - -/// Service to handle favorite products functionality for POS -/// -protocol POSFavouriteProductsServiceProtocol { - /// Returns true if the product is marked as favorite - @MainActor - func isFavorite(productID: Int64) async -> Bool - - /// Returns all favorite product IDs for the current site - @MainActor - func favoriteProductIDs() async -> [Int64] - - /// Marks a product as favorite - @MainActor - func markAsFavorite(productID: Int64) - - /// Removes a product from favorites - @MainActor - func removeFromFavorite(productID: Int64) -} - -final class POSFavouriteProductsService: POSFavouriteProductsServiceProtocol { - private let siteID: Int64 - private let stores: StoresManager - private let featureFlagService: FeatureFlagService - - init(siteID: Int64, - stores: StoresManager = ServiceLocator.stores, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { - self.siteID = siteID - self.stores = stores - self.featureFlagService = featureFlagService - } - - @MainActor - func isFavorite(productID: Int64) async -> Bool { - return await withCheckedContinuation { @MainActor continuation in - stores.dispatch(AppSettingsAction.loadFavoriteProductIDs(siteID: siteID, onCompletion: { savedFavProductIDs in - continuation.resume(returning: savedFavProductIDs.contains(where: { $0 == productID })) - })) - } - } - - @MainActor - func favoriteProductIDs() async -> [Int64] { - guard featureFlagService.isFeatureFlagEnabled(.favoriteProducts) else { - return [] - } - return await withCheckedContinuation { @MainActor continuation in - stores.dispatch(AppSettingsAction.loadFavoriteProductIDs(siteID: siteID, onCompletion: { savedFavProductIDs in - continuation.resume(returning: savedFavProductIDs) - })) - } - } - - @MainActor - func markAsFavorite(productID: Int64) { - let action = AppSettingsAction.setProductIDAsFavorite(productID: productID, siteID: siteID) - stores.dispatch(action) - } - - @MainActor - func removeFromFavorite(productID: Int64) { - let action = AppSettingsAction.removeProductIDAsFavorite(productID: productID, siteID: siteID) - stores.dispatch(action) - } -} diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index 990a5b8b2b3..6b6074f8acb 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -19,6 +19,7 @@ import struct Yosemite.ProductVariation import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItemType import Combine +import protocol Yosemite.POSFavouriteProductsServiceProtocol // MARK: - PreviewProvider helpers // diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index b2eeca6890a..eeaa70df45a 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -886,7 +886,6 @@ 209B15672AD85F070094152A /* OperatingSystemVersion+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209B15662AD85F070094152A /* OperatingSystemVersion+Localization.swift */; }; 209B7A682CEB6742003BDEF0 /* PointOfSalePaymentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209B7A672CEB6742003BDEF0 /* PointOfSalePaymentState.swift */; }; 209CA0EE2B50070D0073D1AC /* WooTabContainerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209CA0ED2B50070D0073D1AC /* WooTabContainerController.swift */; }; - 209E9A732DB6ACF90089F3D2 /* POSFavouriteProductsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209E9A712DB6A7A50089F3D2 /* POSFavouriteProductsService.swift */; }; 209EEF902C762ED5007969A4 /* POSModalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209EEF8F2C762ED5007969A4 /* POSModalManager.swift */; }; 20A130EB2C5A27190058022F /* PointOfSaleAssetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A130EA2C5A27190058022F /* PointOfSaleAssetsTests.swift */; }; 20A3AFE12B0F750B0033AF2D /* MockInPersonPaymentsCashOnDeliveryToggleRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A3AFE02B0F750B0033AF2D /* MockInPersonPaymentsCashOnDeliveryToggleRowViewModel.swift */; }; @@ -4148,7 +4147,6 @@ 209B15662AD85F070094152A /* OperatingSystemVersion+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Localization.swift"; sourceTree = ""; }; 209B7A672CEB6742003BDEF0 /* PointOfSalePaymentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSalePaymentState.swift; sourceTree = ""; }; 209CA0ED2B50070D0073D1AC /* WooTabContainerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooTabContainerController.swift; sourceTree = ""; }; - 209E9A712DB6A7A50089F3D2 /* POSFavouriteProductsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFavouriteProductsService.swift; sourceTree = ""; }; 209EEF8F2C762ED5007969A4 /* POSModalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSModalManager.swift; sourceTree = ""; }; 209FC9C42228DA65BD11D65E /* Pods-WordPressAuthenticator.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressAuthenticator.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressAuthenticator/Pods-WordPressAuthenticator.release-alpha.xcconfig"; sourceTree = ""; }; 20A130EA2C5A27190058022F /* PointOfSaleAssetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleAssetsTests.swift; sourceTree = ""; }; @@ -7628,7 +7626,6 @@ 200BA1572CF092150006DC5B /* Controllers */, 026826A12BF59DED0036F959 /* Presentation */, 026826912BF59D7A0036F959 /* ViewHelpers */, - 209E9A722DB6A7A50089F3D2 /* Services */, 02D1D2D82CD3CD710069A93F /* Analytics */, 2004E2C02C076CCA00D62521 /* Card Present Payments */, 026826972BF59D9E0036F959 /* Utils */, @@ -8414,14 +8411,6 @@ path = "Payments Onboarding"; sourceTree = ""; }; - 209E9A722DB6A7A50089F3D2 /* Services */ = { - isa = PBXGroup; - children = ( - 209E9A712DB6A7A50089F3D2 /* POSFavouriteProductsService.swift */, - ); - path = Services; - sourceTree = ""; - }; 20A130E92C5A26F70058022F /* Tools */ = { isa = PBXGroup; children = ( @@ -17109,7 +17098,6 @@ 021AEF9E2407F55C00029D28 /* PHAssetImageLoader.swift in Sources */, DECE13FB27993F6500816ECD /* TitleAndSubtitleAndStatusTableViewCell.swift in Sources */, 200BA1592CF092280006DC5B /* PointOfSaleItemsController.swift in Sources */, - 209E9A732DB6ACF90089F3D2 /* POSFavouriteProductsService.swift in Sources */, DEFC9BE22B2FF62C00138B05 /* WooAnalyticsEvent+Themes.swift in Sources */, EE35AFA32B0491960074E7AC /* SubscriptionTrialViewModel.swift in Sources */, 26BCA0402C35E9A9000BE96C /* BackgroundTaskRefreshDispatcher.swift in Sources */, diff --git a/Yosemite/Yosemite.xcodeproj/project.pbxproj b/Yosemite/Yosemite.xcodeproj/project.pbxproj index 5d6383781c9..6288b69fcf3 100644 --- a/Yosemite/Yosemite.xcodeproj/project.pbxproj +++ b/Yosemite/Yosemite.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ 2095A64B2D9D82D400CA1849 /* PointOfSalePurchasableItemFetchStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2095A64A2D9D82D400CA1849 /* PointOfSalePurchasableItemFetchStrategy.swift */; }; 209AD3CC2AC1A68800825D76 /* WooPaymentsPayoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209AD3CB2AC1A68800825D76 /* WooPaymentsPayoutService.swift */; }; 209AD3CE2AC1A9C200825D76 /* WooPaymentsPayoutsOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209AD3CD2AC1A9C200825D76 /* WooPaymentsPayoutsOverview.swift */; }; + 209E9A752DB6B43C0089F3D2 /* POSFavouriteProductsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209E9A742DB6B43C0089F3D2 /* POSFavouriteProductsService.swift */; }; 20B9F7842DB0FDDB00512EF5 /* SiteSpecificAppSettingsStoreMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B9F7832DB0FDDB00512EF5 /* SiteSpecificAppSettingsStoreMethods.swift */; }; 20B9F7892DB118AE00512EF5 /* SiteSpecificAppSettingsStoreMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B9F7882DB118AE00512EF5 /* SiteSpecificAppSettingsStoreMethodsTests.swift */; }; 20B9F78B2DB11A7D00512EF5 /* MockSiteSpecificAppSettingsStoreMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B9F78A2DB11A7D00512EF5 /* MockSiteSpecificAppSettingsStoreMethods.swift */; }; @@ -726,6 +727,7 @@ 2095A64A2D9D82D400CA1849 /* PointOfSalePurchasableItemFetchStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSalePurchasableItemFetchStrategy.swift; sourceTree = ""; }; 209AD3CB2AC1A68800825D76 /* WooPaymentsPayoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsPayoutService.swift; sourceTree = ""; }; 209AD3CD2AC1A9C200825D76 /* WooPaymentsPayoutsOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsPayoutsOverview.swift; sourceTree = ""; }; + 209E9A742DB6B43C0089F3D2 /* POSFavouriteProductsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFavouriteProductsService.swift; sourceTree = ""; }; 20B9F7832DB0FDDB00512EF5 /* SiteSpecificAppSettingsStoreMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSpecificAppSettingsStoreMethods.swift; sourceTree = ""; }; 20B9F7882DB118AE00512EF5 /* SiteSpecificAppSettingsStoreMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSpecificAppSettingsStoreMethodsTests.swift; sourceTree = ""; }; 20B9F78A2DB11A7D00512EF5 /* MockSiteSpecificAppSettingsStoreMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSiteSpecificAppSettingsStoreMethods.swift; sourceTree = ""; }; @@ -1601,6 +1603,7 @@ 68B681172D925B170098D5CD /* PointOfSaleCouponService.swift */, 2095A6482D9D81C900CA1849 /* PointOfSaleItemFetchStrategyFactory.swift */, 68EA25372C0876DF00C49AE2 /* PointOfSaleItemService.swift */, + 209E9A742DB6B43C0089F3D2 /* POSFavouriteProductsService.swift */, 6898F3732C0842150039F10A /* PointOfSaleItemServiceProtocol.swift */, 2095A64A2D9D82D400CA1849 /* PointOfSalePurchasableItemFetchStrategy.swift */, 6823533E2D82A90A00F24470 /* POSCoupon.swift */, @@ -2764,6 +2767,7 @@ 7492FADB217FAE4D00ED2C69 /* SiteSetting+ReadOnlyType.swift in Sources */, 209AD3CE2AC1A9C200825D76 /* WooPaymentsPayoutsOverview.swift in Sources */, DEDA8DAF2B1847C80076BF0F /* WordPressThemeStore.swift in Sources */, + 209E9A752DB6B43C0089F3D2 /* POSFavouriteProductsService.swift in Sources */, 03EB99902907B97800F06A39 /* JustInTimeMessage.swift in Sources */, 031C1EAA27B1702800298699 /* WCPayCharge+ReadOnlyConvertible.swift in Sources */, 86969E762CEEEF220032E50F /* MockCouponActionHandler.swift in Sources */, diff --git a/Yosemite/Yosemite/PointOfSale/POSFavouriteProductsService.swift b/Yosemite/Yosemite/PointOfSale/POSFavouriteProductsService.swift new file mode 100644 index 00000000000..792ebcd82f6 --- /dev/null +++ b/Yosemite/Yosemite/PointOfSale/POSFavouriteProductsService.swift @@ -0,0 +1,46 @@ +import Foundation +import Storage + +/// Service to handle favorite products functionality for POS +/// +public protocol POSFavouriteProductsServiceProtocol { + /// Returns true if the product is marked as favorite + func isFavorite(productID: Int64) async -> Bool + + /// Returns all favorite product IDs for the current site + func favoriteProductIDs() async -> [Int64] + + /// Marks a product as favorite + func markAsFavorite(productID: Int64) + + /// Removes a product from favorites + func removeFromFavorite(productID: Int64) +} + +public final class POSFavouriteProductsService: POSFavouriteProductsServiceProtocol { + private let siteID: Int64 + private let siteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStoreMethodsProtocol + + public init(siteID: Int64, + siteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStoreMethodsProtocol? = nil) { + self.siteID = siteID + self.siteSpecificAppSettingsStoreMethods = siteSpecificAppSettingsStoreMethods ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage()) + } + + public func isFavorite(productID: Int64) async -> Bool { + let favoriteProductIDs = siteSpecificAppSettingsStoreMethods.loadFavoriteProductIDs(siteID: siteID) + return favoriteProductIDs.contains(productID) + } + + public func favoriteProductIDs() async -> [Int64] { + return siteSpecificAppSettingsStoreMethods.loadFavoriteProductIDs(siteID: siteID) + } + + public func markAsFavorite(productID: Int64) { + siteSpecificAppSettingsStoreMethods.setProductIDAsFavorite(productID: productID, siteID: siteID) + } + + public func removeFromFavorite(productID: Int64) { + siteSpecificAppSettingsStoreMethods.removeProductIDAsFavorite(productID: productID, siteID: siteID) + } +} diff --git a/Yosemite/Yosemite/Stores/AppSettingsStore.swift b/Yosemite/Yosemite/Stores/AppSettingsStore.swift index f6906f7f876..7b5395d41a1 100644 --- a/Yosemite/Yosemite/Stores/AppSettingsStore.swift +++ b/Yosemite/Yosemite/Stores/AppSettingsStore.swift @@ -1220,30 +1220,15 @@ private extension AppSettingsStore { // private extension AppSettingsStore { func setProductIDAsFavorite(productID: Int64, siteID: Int64) { - let storeSettings = getStoreSettings(for: siteID) - - let updatedSettings: GeneralStoreSettings - updatedSettings = storeSettings.copy(favoriteProductIDs: Array(Set(storeSettings.favoriteProductIDs + [productID]))) - - setStoreSettings(settings: updatedSettings, for: siteID) + siteSpecificAppSettingsStoreMethods.setProductIDAsFavorite(productID: productID, siteID: siteID) } func removeProductIDAsFavorite(productID: Int64, siteID: Int64) { - let storeSettings = getStoreSettings(for: siteID) - var savedFavProductIDs = storeSettings.favoriteProductIDs - - guard let indexOfFavProductToBeRemoved = savedFavProductIDs.firstIndex(of: productID) else { - return - } - savedFavProductIDs.remove(at: indexOfFavProductToBeRemoved) - - let updatedSettings: GeneralStoreSettings - updatedSettings = storeSettings.copy(favoriteProductIDs: savedFavProductIDs) - setStoreSettings(settings: updatedSettings, for: siteID) + siteSpecificAppSettingsStoreMethods.removeProductIDAsFavorite(productID: productID, siteID: siteID) } func loadFavoriteProductIDs(for siteID: Int64, onCompletion: ([Int64]) -> Void) { - onCompletion(getStoreSettings(for: siteID).favoriteProductIDs) + onCompletion(siteSpecificAppSettingsStoreMethods.loadFavoriteProductIDs(siteID: siteID)) } } diff --git a/Yosemite/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift b/Yosemite/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift index 7fbe31065d4..b3a61143415 100644 --- a/Yosemite/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift +++ b/Yosemite/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift @@ -11,6 +11,11 @@ public protocol SiteSpecificAppSettingsStoreMethodsProtocol { // Search history methods func getSearchTerms(for itemType: POSItemType, siteID: Int64) -> [String] func setSearchTerms(_ terms: [String], for itemType: POSItemType, siteID: Int64) + + // Favorite products methods + func setProductIDAsFavorite(productID: Int64, siteID: Int64) + func removeProductIDAsFavorite(productID: Int64, siteID: Int64) + func loadFavoriteProductIDs(siteID: Int64) -> [Int64] } /// Methods for managing site-specific app settings @@ -98,6 +103,32 @@ extension SiteSpecificAppSettingsStoreMethods { } } +// MARK: - Favorite Products +extension SiteSpecificAppSettingsStoreMethods { + func setProductIDAsFavorite(productID: Int64, siteID: Int64) { + let storeSettings = getStoreSettings(for: siteID) + let updatedSettings = storeSettings.copy(favoriteProductIDs: Array(Set(storeSettings.favoriteProductIDs + [productID]))) + setStoreSettings(settings: updatedSettings, for: siteID) + } + + func removeProductIDAsFavorite(productID: Int64, siteID: Int64) { + let storeSettings = getStoreSettings(for: siteID) + var savedFavProductIDs = storeSettings.favoriteProductIDs + + guard let indexOfFavProductToBeRemoved = savedFavProductIDs.firstIndex(of: productID) else { + return + } + savedFavProductIDs.remove(at: indexOfFavProductToBeRemoved) + + let updatedSettings = storeSettings.copy(favoriteProductIDs: savedFavProductIDs) + setStoreSettings(settings: updatedSettings, for: siteID) + } + + func loadFavoriteProductIDs(siteID: Int64) -> [Int64] { + return getStoreSettings(for: siteID).favoriteProductIDs + } +} + // MARK: - Constants private enum Constants { static let generalStoreSettingsFileName = "general-store-settings.plist" diff --git a/Yosemite/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift b/Yosemite/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift index 5f990bc3a45..f36276814fe 100644 --- a/Yosemite/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift +++ b/Yosemite/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift @@ -27,6 +27,17 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor var spySetSearchTermsSiteID: Int64? var mockSearchTerms: [POSItemType: [String]] = [:] + // Favorite products properties + var setProductIDAsFavoriteCalled = false + var removeProductIDAsFavoriteCalled = false + var loadFavoriteProductIDsCalled = false + var spySetProductIDAsFavoriteID: Int64? + var spySetProductIDAsFavoriteSiteID: Int64? + var spyRemoveProductIDAsFavoriteID: Int64? + var spyRemoveProductIDAsFavoriteSiteID: Int64? + var spyLoadFavoriteProductIDsSiteID: Int64? + var mockFavoriteProductIDs: [Int64] = [] + func getStoreSettings(for siteID: Int64) -> GeneralStoreSettings { getStoreSettingsCalled = true return storeSettings @@ -83,4 +94,30 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor spySetSearchTermsSiteID = siteID mockSearchTerms[itemType] = terms } + + // MARK: - Favorite Products Methods + + func setProductIDAsFavorite(productID: Int64, siteID: Int64) { + setProductIDAsFavoriteCalled = true + spySetProductIDAsFavoriteID = productID + spySetProductIDAsFavoriteSiteID = siteID + if siteID == currentSiteID { + mockFavoriteProductIDs.append(productID) + } + } + + func removeProductIDAsFavorite(productID: Int64, siteID: Int64) { + removeProductIDAsFavoriteCalled = true + spyRemoveProductIDAsFavoriteID = productID + spyRemoveProductIDAsFavoriteSiteID = siteID + if siteID == currentSiteID { + mockFavoriteProductIDs.removeAll { $0 == productID } + } + } + + func loadFavoriteProductIDs(siteID: Int64) -> [Int64] { + loadFavoriteProductIDsCalled = true + spyLoadFavoriteProductIDsSiteID = siteID + return mockFavoriteProductIDs + } } diff --git a/Yosemite/YosemiteTests/Stores/Helpers/SiteSpecificAppSettingsStoreMethodsTests.swift b/Yosemite/YosemiteTests/Stores/Helpers/SiteSpecificAppSettingsStoreMethodsTests.swift index 9114535d00f..21adc4a8b6d 100644 --- a/Yosemite/YosemiteTests/Stores/Helpers/SiteSpecificAppSettingsStoreMethodsTests.swift +++ b/Yosemite/YosemiteTests/Stores/Helpers/SiteSpecificAppSettingsStoreMethodsTests.swift @@ -245,6 +245,71 @@ final class SiteSpecificAppSettingsStoreMethodsTests: XCTestCase { XCTAssertEqual(retrievedVariationTerms, variationTerms) XCTAssertEqual(retrievedCouponTerms, couponTerms) } + + // MARK: - Favorite Products Tests + + func test_setProductIDAsFavorite_adds_product_to_favorites() throws { + // Given + let existingSettings = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: GeneralStoreSettings()]) + try fileStorage.write(existingSettings, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + + // When + sut.setProductIDAsFavorite(productID: 123, siteID: siteID) + + // Then + let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + XCTAssertEqual(savedData.storeSettingsBySite[siteID]?.favoriteProductIDs, [123]) + } + + func test_setProductIDAsFavorite_preserves_existing_favorites() throws { + // Given + let existingSettings = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: GeneralStoreSettings(favoriteProductIDs: [123])]) + try fileStorage.write(existingSettings, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + + // When + sut.setProductIDAsFavorite(productID: 456, siteID: siteID) + + // Then + let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + XCTAssertEqual(Set(savedData.storeSettingsBySite[siteID]?.favoriteProductIDs ?? []), Set([123, 456])) + } + + func test_removeProductIDAsFavorite_removes_product_from_favorites() throws { + // Given + let existingSettings = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: GeneralStoreSettings(favoriteProductIDs: [123, 456])]) + try fileStorage.write(existingSettings, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + + // When + sut.removeProductIDAsFavorite(productID: 123, siteID: siteID) + + // Then + let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + XCTAssertEqual(savedData.storeSettingsBySite[siteID]?.favoriteProductIDs, [456]) + } + + func test_loadFavoriteProductIDs_returns_empty_array_when_no_favorites() throws { + // Given + let existingSettings = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: GeneralStoreSettings()]) + try fileStorage.write(existingSettings, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + + // When + let favorites = sut.loadFavoriteProductIDs(siteID: siteID) + + // Then + XCTAssertEqual(favorites, []) + } + + func test_loadFavoriteProductIDs_returns_saved_favorites() throws { + // Given + let existingSettings = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: GeneralStoreSettings(favoriteProductIDs: [123, 456])]) + try fileStorage.write(existingSettings, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL) + + // When + let favorites = sut.loadFavoriteProductIDs(siteID: siteID) + + // Then + XCTAssertEqual(favorites, [123, 456]) + } } // MARK: - Mock FileStorage From 84c33ee2281764f5d2bd736f80a244f5e31aa81f Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 21 Apr 2025 18:23:51 +0100 Subject: [PATCH 3/4] Use a header action button for the favourites filter --- .../POS/Presentation/ItemListView.swift | 2 +- .../POSPageHeaderActionButton.swift | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift index c24d3689431..b1dccaea184 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift @@ -177,7 +177,7 @@ private extension ItemListView { .renderedIf(!shouldShowSearchField) .transition(.opacity.combined(with: .scale)) - Button { + POSPageHeaderActionButton { showFavoritesOnly.toggle() Task { @MainActor in if showFavoritesOnly { diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift index 64636aba5d8..c278cc836f0 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift @@ -1,19 +1,24 @@ import SwiftUI -struct POSPageHeaderActionButton: View { - let systemName: String +struct POSPageHeaderActionButton: View { + let label: Label let action: () -> Void @ScaledMetric private var scaledButtonSize: CGFloat = POSHeaderLayoutConstants.minHeight private var constrainedButtonSize: CGFloat { max(POSHeaderLayoutConstants.minHeight, min(scaledButtonSize, POSHeaderLayoutConstants.minHeight * 1.2)) } + init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) { + self.action = action + self.label = label() + } + var body: some View { Button(action: action) { Circle() .foregroundColor(.posSurfaceContainerLow) .overlay { - Image(systemName: systemName) + label .font(.posButtonSymbolSmall) .foregroundColor(.posOnSurface) .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) @@ -23,3 +28,12 @@ struct POSPageHeaderActionButton: View { .fixedSize() } } + +// Convenience initializer for backward compatibility +extension POSPageHeaderActionButton where Label == Image { + init(systemName: String, action: @escaping () -> Void) { + self.init(action: action) { + Image(systemName: systemName) + } + } +} From c320e0f058889dd9f2d8e8be2fc1e09cf30e4bb5 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 21 Apr 2025 18:36:15 +0100 Subject: [PATCH 4/4] Tidy up toggle button for favourite products --- .../Presentation/Item Selector/ItemList.swift | 56 +++---------------- .../Item Selector/ParentProductCardView.swift | 35 +++++++++++- .../PointOfSaleItemListCardConstants.swift | 1 + .../Item Selector/SimpleProductCardView.swift | 29 ++++++++++ 4 files changed, 72 insertions(+), 49 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift index bbbb377766b..5dae09e1b63 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift @@ -140,36 +140,14 @@ private struct ItemListRow: View { @Environment(PointOfSaleAggregateModel.self) private var posModel let analytics: Analytics = ServiceLocator.analytics - @State private var isFavorite: Bool = false - var body: some View { switch item { case let .simpleProduct(product): - HStack { - Button(action: { - itemActionHandler.handleTap(item) - }, label: { - SimpleProductCardView(product: product) - }) - - Button(action: { - if isFavorite { - posModel.favoriteProductsService.removeFromFavorite(productID: product.productID) - } else { - posModel.favoriteProductsService.markAsFavorite(productID: product.productID) - } - isFavorite.toggle() - }) { - Image(systemName: isFavorite ? "star.fill" : "star") - .foregroundColor(.posOnSurface) - .font(.posButtonSymbolLarge) - .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) - } - .padding(.trailing, POSPadding.small) - } - .task { - isFavorite = await posModel.favoriteProductsService.isFavorite(productID: product.productID) - } + Button(action: { + itemActionHandler.handleTap(item) + }, label: { + SimpleProductCardView(product: product) + }) case let .variableParentProduct(parentProduct): HStack { @@ -177,7 +155,8 @@ private struct ItemListRow: View { NavigationLink(value: item) { ParentProductCardView(name: parentProduct.name, imageSource: parentProduct.productImageSource, - detailText: Localization.variationsAvailable) + detailText: Localization.variationsAvailable, + productID: parentProduct.productID) } } else { Button(action: { @@ -185,27 +164,10 @@ private struct ItemListRow: View { }, label: { ParentProductCardView(name: parentProduct.name, imageSource: parentProduct.productImageSource, - detailText: Localization.variationsAvailable) + detailText: Localization.variationsAvailable, + productID: parentProduct.productID) }) } - - Button(action: { - if isFavorite { - posModel.favoriteProductsService.removeFromFavorite(productID: parentProduct.productID) - } else { - posModel.favoriteProductsService.markAsFavorite(productID: parentProduct.productID) - } - isFavorite.toggle() - }) { - Image(systemName: isFavorite ? "star.fill" : "star") - .foregroundColor(.posOnSurface) - .font(.posButtonSymbolLarge) - .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) - } - .padding(.trailing, POSPadding.small) - } - .task { - isFavorite = await posModel.favoriteProductsService.isFavorite(productID: parentProduct.productID) } case let .variation(variation): diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ParentProductCardView.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ParentProductCardView.swift index f08e3684b60..7fa0fc88cc2 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Selector/ParentProductCardView.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ParentProductCardView.swift @@ -1,10 +1,14 @@ import SwiftUI /// Displays a card for a parent product in POS. +@available(iOS 17.0, *) struct ParentProductCardView: View { private let name: String private let imageSource: String? private let detailText: String + private let productID: Int64 + @Environment(PointOfSaleAggregateModel.self) private var posModel + @State private var isFavorite: Bool = false @ScaledMetric private var scale: CGFloat = 1.0 @@ -12,10 +16,11 @@ struct ParentProductCardView: View { min(Constants.productCardSize * scale, Constants.maximumProductCardSize) } - init(name: String, imageSource: String?, detailText: String) { + init(name: String, imageSource: String?, detailText: String, productID: Int64) { self.name = name self.imageSource = imageSource self.detailText = detailText + self.productID = productID } var body: some View { @@ -39,19 +44,45 @@ struct ParentProductCardView: View { .padding(.horizontal, Constants.horizontalTextPadding * (1 / scale)) .padding(.vertical, Constants.verticalTextPadding * (1 / scale)) Spacer() + + Button(action: { + if isFavorite { + posModel.favoriteProductsService.removeFromFavorite(productID: productID) + } else { + posModel.favoriteProductsService.markAsFavorite(productID: productID) + } + isFavorite.toggle() + }) { + Circle() + .foregroundColor(.posSurfaceContainerLow) + .overlay { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.posButtonSymbolSmall) + .foregroundColor(.posOnSurface) + .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) + } + .frame(width: Constants.favoriteButtonSize, height: Constants.favoriteButtonSize) + } + .padding(.trailing, Constants.horizontalTextPadding * (1 / scale)) } .frame(maxWidth: .infinity, idealHeight: dimension) .background(Constants.backgroundColor) .posItemCardBorderStyles() + .task { + isFavorite = await posModel.favoriteProductsService.isFavorite(productID: productID) + } } } private typealias Constants = PointOfSaleItemListCardConstants #if DEBUG +@available(iOS 17.0, *) #Preview { ParentProductCardView(name: "Parent product", imageSource: nil, - detailText: "Detail text") + detailText: "Detail text", + productID: 1) + .environment(POSPreviewHelpers.makePreviewAggregateModel()) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/PointOfSaleItemListCardConstants.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/PointOfSaleItemListCardConstants.swift index 1f4074ff852..ef8bcad07ed 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Selector/PointOfSaleItemListCardConstants.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/PointOfSaleItemListCardConstants.swift @@ -14,4 +14,5 @@ enum PointOfSaleItemListCardConstants { static let backgroundColor: Color = .posSurfaceContainerLowest static let titleColor: Color = .posOnSurface static let detailColor: Color = .posOnSurfaceVariantHighest + static let favoriteButtonSize: CGFloat = 32 } diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/SimpleProductCardView.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/SimpleProductCardView.swift index fc215eae048..306291e9122 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Selector/SimpleProductCardView.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/SimpleProductCardView.swift @@ -1,8 +1,11 @@ import struct Yosemite.POSSimpleProduct import SwiftUI +@available(iOS 17.0, *) struct SimpleProductCardView: View { private let product: POSSimpleProduct + @Environment(PointOfSaleAggregateModel.self) private var posModel + @State private var isFavorite: Bool = false @ScaledMetric private var scale: CGFloat = 1.0 @Environment(\.dynamicTypeSize) var dynamicTypeSize @@ -36,23 +39,49 @@ struct SimpleProductCardView: View { .padding(.horizontal, Constants.horizontalTextPadding * (1 / scale)) .padding(.vertical, Constants.verticalTextPadding * (1 / scale)) Spacer() + + Button(action: { + if isFavorite { + posModel.favoriteProductsService.removeFromFavorite(productID: product.productID) + } else { + posModel.favoriteProductsService.markAsFavorite(productID: product.productID) + } + isFavorite.toggle() + }) { + Circle() + .foregroundColor(.posSurfaceContainerLow) + .overlay { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.posButtonSymbolSmall) + .foregroundColor(.posOnSurface) + .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) + } + .frame(width: Constants.favoriteButtonSize, height: Constants.favoriteButtonSize) + } + .padding(.trailing, Constants.horizontalTextPadding * (1 / scale)) } .frame(maxWidth: .infinity, idealHeight: dimension) .background(Constants.backgroundColor) .posItemCardBorderStyles() + .task { + isFavorite = await posModel.favoriteProductsService.isFavorite(productID: product.productID) + } } } +@available(iOS 17.0, *) private extension SimpleProductCardView { typealias Constants = PointOfSaleItemListCardConstants } #if DEBUG +@available(iOS 17.0, *) #Preview { SimpleProductCardView(product: POSSimpleProduct(id: UUID(), name: "Product name", formattedPrice: "$3.00", productID: 123, price: "3.00")) + .environment(POSPreviewHelpers.makePreviewAggregateModel()) } #endif