From cca539e581cd7fe6a2facfd2615924d7df69705b Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 14:08:24 +0800 Subject: [PATCH 01/14] Update `POSEligibilityChecker` to observe IPP onboarding state and return `isEligible` as a publisher. --- .../Settings/POS/POSEligibilityChecker.swift | 47 ++++++++++++------- .../Hub Menu/HubMenuViewModel.swift | 28 ++++++----- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index fefbe6d52d0..7626dee2e18 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import UIKit import class WooFoundation.CurrencySettings @@ -7,38 +8,52 @@ import struct Yosemite.SiteSetting /// Determines whether the POS entry point can be shown based on the selected store and feature gates. final class POSEligibilityChecker { + @Published private(set) var isEligible: Bool = false + private var onboardingStateSubscription: AnyCancellable? + private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol - private let siteSettings: [SiteSetting] + private let siteSettings: SelectedSiteSettings private let currencySettings: CurrencySettings private let featureFlagService: FeatureFlagService init(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), - siteSettings: [SiteSetting] = ServiceLocator.selectedSiteSettings.siteSettings, + siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, currencySettings: CurrencySettings = ServiceLocator.currencySettings, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { self.siteSettings = siteSettings self.currencySettings = currencySettings self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding self.featureFlagService = featureFlagService + observeOnboardingStateForEligibilityCheck() } +} +private extension POSEligibilityChecker { /// Returns whether the selected store is eligible for POS. - func isEligible() -> Bool { - // Always checks the main POS feature flag before any other checks. - guard featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) else { - return false + func observeOnboardingStateForEligibilityCheck() { + // Conditions that are fixed per lifetime of the menu tab. + let isTablet = UIDevice.current.userInterfaceIdiom == .pad + let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) + guard isTablet && isFeatureFlagEnabled else { + isEligible = false + return } - let isCountryCodeUS = SiteAddress(siteSettings: siteSettings).countryCode == CountryCode.US - let isCurrencyUSD = currencySettings.currencyCode == .USD + cardPresentPaymentsOnboarding.statePublisher + .filter { [weak self] _ in + self?.isEligibleFromSiteChecks() ?? false + } + .map { onboardingState in + // Woo Payments plugin enabled and user setup complete + onboardingState == .completed(plugin: .wcPayOnly) || onboardingState == .completed(plugin: .wcPayPreferred) + } + .assign(to: &$isEligible) + } - // Tablet device - return UIDevice.current.userInterfaceIdiom == .pad - // Woo Payments plugin enabled and user setup complete - && (cardPresentPaymentsOnboarding.state == .completed(plugin: .wcPayOnly) || cardPresentPaymentsOnboarding.state == .completed(plugin: .wcPayPreferred)) - // USD currency - && isCurrencyUSD - // US store location - && isCountryCodeUS + func isEligibleFromSiteChecks() -> Bool { + // Conditions that can change if site settings are synced during the lifetime of the menu tab. + let isCountryCodeUS = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode == .US + let isCurrencyUSD = currencySettings.currencyCode == .USD + return isCountryCodeUS && isCurrencyUSD } } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index dc21abc89f3..b70ec860876 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -67,6 +67,8 @@ final class HubMenuViewModel: ObservableObject { private let stores: StoresManager private let featureFlagService: FeatureFlagService private let generalAppSettings: GeneralAppSettingsStorage + private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol + private let posEligibilityChecker: POSEligibilityChecker private(set) var productReviewFromNoteParcel: ProductReviewFromNoteParcel? @@ -114,8 +116,14 @@ final class HubMenuViewModel: ObservableObject { self.generalAppSettings = generalAppSettings self.switchStoreEnabled = stores.isAuthenticatedWithoutWPCom == false self.blazeEligibilityChecker = blazeEligibilityChecker + self.cardPresentPaymentsOnboarding = CardPresentPaymentsOnboardingUseCase() + self.posEligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: cardPresentPaymentsOnboarding, + siteSettings: ServiceLocator.selectedSiteSettings, + currencySettings: ServiceLocator.currencySettings, + featureFlagService: featureFlagService) observeSiteForUIUpdates() observePlanName() + setupPOSElement() tapToPayBadgePromotionChecker.$shouldShowTapToPayBadges.share().assign(to: &$shouldShowNewFeatureBadgeOnPayments) } @@ -126,7 +134,6 @@ final class HubMenuViewModel: ObservableObject { /// Resets the menu elements displayed on the menu. /// func setupMenuElements() { - setupPOSElement() setupSettingsElements() setupGeneralElements() } @@ -138,16 +145,15 @@ final class HubMenuViewModel: ObservableObject { } private func setupPOSElement() { - let isBetaFeatureEnabled = generalAppSettings.betaFeatureEnabled(.pointOfSale) - let eligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(), - siteSettings: ServiceLocator.selectedSiteSettings.siteSettings, - currencySettings: ServiceLocator.currencySettings, - featureFlagService: featureFlagService) - if isBetaFeatureEnabled && eligibilityChecker.isEligible() { - posElement = PointOfSaleEntryPoint() - } else { - posElement = nil - } + Publishers.CombineLatest(generalAppSettings.betaFeatureEnabledPublisher(.pointOfSale), posEligibilityChecker.$isEligible) + .map { isBetaFeatureEnabled, isEligibleForPOS in + if isBetaFeatureEnabled && isEligibleForPOS { + return PointOfSaleEntryPoint() + } else { + return nil + } + } + .assign(to: &$posElement) } private func setupSettingsElements() { From ea463200bb7e822239ff1c8b695c3efdcbf8f633 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 14:45:00 +0800 Subject: [PATCH 02/14] Update BetaFeaturesConfiguration view to use a view model that observes POS eligibility for available features. --- WooCommerce/Classes/Model/BetaFeature.swift | 25 ++------ .../BetaFeaturesConfiguration.swift | 61 +++++++++++++++++-- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/WooCommerce/Classes/Model/BetaFeature.swift b/WooCommerce/Classes/Model/BetaFeature.swift index 319c74d4431..2912c85a8c1 100644 --- a/WooCommerce/Classes/Model/BetaFeature.swift +++ b/WooCommerce/Classes/Model/BetaFeature.swift @@ -1,3 +1,4 @@ +import Combine import Storage import protocol WooFoundation.WooAnalyticsEventPropertyType @@ -52,21 +53,6 @@ extension BetaFeature { } } - var isAvailable: Bool { - switch self { - case .inAppPurchases: - return ServiceLocator.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) - case .pointOfSale: - return POSEligibilityChecker().isEligible() - default: - return true - } - } - - static var availableFeatures: [Self] { - allCases.filter(\.isAvailable) - } - func analyticsProperties(toggleState enabled: Bool) -> [String: WooAnalyticsEventPropertyType] { var properties = ["state": enabled ? "on" : "off"] if analyticsStat == .settingsBetaFeatureToggled { @@ -78,10 +64,11 @@ extension BetaFeature { extension GeneralAppSettingsStorage { func betaFeatureEnabled(_ feature: BetaFeature) -> Bool { - guard feature.isAvailable else { - return false - } - return value(for: feature.settingsKey) + value(for: feature.settingsKey) + } + + func betaFeatureEnabledPublisher(_ feature: BetaFeature) -> AnyPublisher { + publisher(for: feature.settingsKey) } func betaFeatureEnabledBinding(_ feature: BetaFeature) -> Binding { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift index b3f6e4b5a1e..2df3d1937dc 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift @@ -1,10 +1,12 @@ import SwiftUI import Yosemite +import protocol Experiments.FeatureFlagService +import struct Storage.GeneralAppSettingsStorage final class BetaFeaturesConfigurationViewController: UIHostingController { init() { - super.init(rootView: BetaFeaturesConfiguration()) + super.init(rootView: BetaFeaturesConfiguration(viewModel: .init())) } required dynamic init?(coder aDecoder: NSCoder) { @@ -12,14 +14,63 @@ final class BetaFeaturesConfigurationViewController: UIHostingController Binding { + appSettings.betaFeatureEnabledBinding(feature) + } +} + +private extension BetaFeaturesConfigurationViewModel { + func observePOSEligibilityForAvailableFeatures() { + posEligibilityChecker.$isEligible + .map { [weak self] isEligibleForPOS in + guard let self else { + return [] + } + return BetaFeature.allCases.filter { betaFeature in + switch betaFeature { + case .viewAddOns: + return true + case .inAppPurchases: + return self.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) + case .pointOfSale: + return isEligibleForPOS + } + } + } + .assign(to: &$availableFeatures) + } +} + struct BetaFeaturesConfiguration: View { - let appSettings = ServiceLocator.generalAppSettings + @StateObject private var viewModel: BetaFeaturesConfigurationViewModel + + init(viewModel: BetaFeaturesConfigurationViewModel) { + self._viewModel = .init(wrappedValue: viewModel) + } var body: some View { List { - ForEach(BetaFeature.availableFeatures) { feature in + ForEach(viewModel.availableFeatures) { feature in Section(footer: Text(feature.description)) { - TitleAndToggleRow(title: feature.title, isOn: appSettings.betaFeatureEnabledBinding(feature)) + TitleAndToggleRow(title: feature.title, isOn: viewModel.isOn(feature: feature)) } } } @@ -36,7 +87,7 @@ private enum Localization { struct BetaFeaturesConfiguration_Previews: PreviewProvider { static var previews: some View { NavigationView { - BetaFeaturesConfiguration() + BetaFeaturesConfiguration(viewModel: .init()) } } } From c2ed3e415936e69af59dcb1dd477151c21568f8b Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 14:46:28 +0800 Subject: [PATCH 03/14] Remove unused dependency to help with readability. --- .../CardPresentPaymentsOnboardingUseCase.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift index b3aeb79b542..431d59b4443 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingUseCase.swift @@ -54,7 +54,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU let storageManager: StorageManagerType let stores: StoresManager let configurationLoader: CardPresentConfigurationLoader - let featureFlagService: FeatureFlagService private let cardPresentPluginsDataProvider: CardPresentPluginsDataProvider private let cardPresentPaymentOnboardingStateCache: CardPresentPaymentOnboardingStateCache private let analytics: Analytics @@ -72,7 +71,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU init( storageManager: StorageManagerType = ServiceLocator.storageManager, stores: StoresManager = ServiceLocator.stores, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, cardPresentPaymentOnboardingStateCache: CardPresentPaymentOnboardingStateCache = CardPresentPaymentOnboardingStateCache.shared, analytics: Analytics = ServiceLocator.analytics ) { @@ -80,7 +78,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU self.stores = stores self.configurationLoader = .init(stores: stores) self.cardPresentPluginsDataProvider = .init(storageManager: storageManager, stores: stores, configuration: configurationLoader.configuration) - self.featureFlagService = featureFlagService self.cardPresentPaymentOnboardingStateCache = cardPresentPaymentOnboardingStateCache self.analytics = analytics From 79d3f0341428e4744a0b58cfd616efc31bc57eeb Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 14:47:25 +0800 Subject: [PATCH 04/14] Move `BetaFeaturesConfigurationViewModel` to its own file. --- .../BetaFeaturesConfiguration.swift | 47 ------------------ .../BetaFeaturesConfigurationViewModel.swift | 48 +++++++++++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 4 ++ 3 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift index 2df3d1937dc..da2c77b3390 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift @@ -1,7 +1,5 @@ import SwiftUI import Yosemite -import protocol Experiments.FeatureFlagService -import struct Storage.GeneralAppSettingsStorage final class BetaFeaturesConfigurationViewController: UIHostingController { @@ -14,51 +12,6 @@ final class BetaFeaturesConfigurationViewController: UIHostingController Binding { - appSettings.betaFeatureEnabledBinding(feature) - } -} - -private extension BetaFeaturesConfigurationViewModel { - func observePOSEligibilityForAvailableFeatures() { - posEligibilityChecker.$isEligible - .map { [weak self] isEligibleForPOS in - guard let self else { - return [] - } - return BetaFeature.allCases.filter { betaFeature in - switch betaFeature { - case .viewAddOns: - return true - case .inAppPurchases: - return self.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) - case .pointOfSale: - return isEligibleForPOS - } - } - } - .assign(to: &$availableFeatures) - } -} - struct BetaFeaturesConfiguration: View { @StateObject private var viewModel: BetaFeaturesConfigurationViewModel diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift new file mode 100644 index 00000000000..87a0e9f09a8 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift @@ -0,0 +1,48 @@ +import SwiftUI +import protocol Experiments.FeatureFlagService +import struct Storage.GeneralAppSettingsStorage + +final class BetaFeaturesConfigurationViewModel: ObservableObject { + @Published private(set) var availableFeatures: [BetaFeature] = [] + private let appSettings: GeneralAppSettingsStorage + private let featureFlagService: FeatureFlagService + private let posEligibilityChecker: POSEligibilityChecker + + init(appSettings: GeneralAppSettingsStorage = ServiceLocator.generalAppSettings, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + posEligibilityChecker: POSEligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(), + siteSettings: ServiceLocator.selectedSiteSettings, + currencySettings: ServiceLocator.currencySettings, + featureFlagService: ServiceLocator.featureFlagService)) { + self.appSettings = appSettings + self.featureFlagService = featureFlagService + self.posEligibilityChecker = posEligibilityChecker + observePOSEligibilityForAvailableFeatures() + } + + func isOn(feature: BetaFeature) -> Binding { + appSettings.betaFeatureEnabledBinding(feature) + } +} + +private extension BetaFeaturesConfigurationViewModel { + func observePOSEligibilityForAvailableFeatures() { + posEligibilityChecker.$isEligible + .map { [weak self] isEligibleForPOS in + guard let self else { + return [] + } + return BetaFeature.allCases.filter { betaFeature in + switch betaFeature { + case .viewAddOns: + return true + case .inAppPurchases: + return self.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) + case .pointOfSale: + return isEligibleForPOS + } + } + } + .assign(to: &$availableFeatures) + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 9adab9e153d..6fe8a78cf74 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ 023A059A24135F2600E3FC99 /* ReviewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023A059824135F2600E3FC99 /* ReviewsViewController.swift */; }; 023A059B24135F2600E3FC99 /* ReviewsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 023A059924135F2600E3FC99 /* ReviewsViewController.xib */; }; 023B3EBA2A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */; }; + 023BD5842BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */; }; 023D1DD124AB2D05002B03A3 /* ProductListSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */; }; 023D692E2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */; }; 023D69442588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */; }; @@ -2945,6 +2946,7 @@ 023A059824135F2600E3FC99 /* ReviewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsViewController.swift; sourceTree = ""; }; 023A059924135F2600E3FC99 /* ReviewsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReviewsViewController.xib; sourceTree = ""; }; 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAnnouncementViewModelTests.swift; sourceTree = ""; }; + 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesConfigurationViewModel.swift; sourceTree = ""; }; 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListSelectorViewController.swift; sourceTree = ""; }; 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommand.swift; sourceTree = ""; }; 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommandTests.swift; sourceTree = ""; }; @@ -6825,6 +6827,7 @@ isa = PBXGroup; children = ( E1E649EA28461EDF0070B194 /* BetaFeaturesConfiguration.swift */, + 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */, ); path = "Beta features"; sourceTree = ""; @@ -13671,6 +13674,7 @@ D85A3C5226C15DE200C0E026 /* InPersonPaymentsPluginNotSupportedVersionView.swift in Sources */, EE57C11D297AC27300BC31E7 /* TrackEventRequestNotificationHandler.swift in Sources */, DE5746312B43F6180034B10D /* BlazeCampaignCreationFormViewModel.swift in Sources */, + 023BD5842BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift in Sources */, 680E36B72BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift in Sources */, CE63023D2BAAEF2C00E3325C /* CustomersListView.swift in Sources */, CE583A0B2107937F00D73C1C /* TextViewTableViewCell.swift in Sources */, From be7d81323a8403b14584d9ee78a3d322a6e3349d Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 14:55:36 +0800 Subject: [PATCH 05/14] Create a protocol for `POSEligibilityChecker` to be used in the use cases. --- .../BetaFeaturesConfigurationViewModel.swift | 14 ++++++++------ .../Settings/POS/POSEligibilityChecker.swift | 17 +++++++++++++---- .../ViewRelated/Hub Menu/HubMenuViewModel.swift | 4 ++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift index 87a0e9f09a8..8c60d16477c 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift @@ -6,14 +6,16 @@ final class BetaFeaturesConfigurationViewModel: ObservableObject { @Published private(set) var availableFeatures: [BetaFeature] = [] private let appSettings: GeneralAppSettingsStorage private let featureFlagService: FeatureFlagService - private let posEligibilityChecker: POSEligibilityChecker + private let posEligibilityChecker: POSEligibilityCheckerProtocol init(appSettings: GeneralAppSettingsStorage = ServiceLocator.generalAppSettings, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - posEligibilityChecker: POSEligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(), - siteSettings: ServiceLocator.selectedSiteSettings, - currencySettings: ServiceLocator.currencySettings, - featureFlagService: ServiceLocator.featureFlagService)) { + posEligibilityChecker: POSEligibilityCheckerProtocol = POSEligibilityChecker( + cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(), + siteSettings: ServiceLocator.selectedSiteSettings, + currencySettings: ServiceLocator.currencySettings, + featureFlagService: ServiceLocator.featureFlagService + )) { self.appSettings = appSettings self.featureFlagService = featureFlagService self.posEligibilityChecker = posEligibilityChecker @@ -27,7 +29,7 @@ final class BetaFeaturesConfigurationViewModel: ObservableObject { private extension BetaFeaturesConfigurationViewModel { func observePOSEligibilityForAvailableFeatures() { - posEligibilityChecker.$isEligible + posEligibilityChecker.isEligible .map { [weak self] isEligibleForPOS in guard let self else { return [] diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index 7626dee2e18..5070fb9fc9b 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -6,9 +6,18 @@ import enum WooFoundation.CountryCode import protocol Experiments.FeatureFlagService import struct Yosemite.SiteSetting +protocol POSEligibilityCheckerProtocol { + /// As POS eligibility can change from site settings and card payment onboarding state, it's recommended to observe the eligibility value. + var isEligible: AnyPublisher { get } +} + /// Determines whether the POS entry point can be shown based on the selected store and feature gates. -final class POSEligibilityChecker { - @Published private(set) var isEligible: Bool = false +final class POSEligibilityChecker: POSEligibilityCheckerProtocol { + var isEligible: AnyPublisher { + $isEligibleValue.eraseToAnyPublisher() + } + + @Published private var isEligibleValue: Bool = false private var onboardingStateSubscription: AnyCancellable? private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol @@ -35,7 +44,7 @@ private extension POSEligibilityChecker { let isTablet = UIDevice.current.userInterfaceIdiom == .pad let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) guard isTablet && isFeatureFlagEnabled else { - isEligible = false + isEligibleValue = false return } @@ -47,7 +56,7 @@ private extension POSEligibilityChecker { // Woo Payments plugin enabled and user setup complete onboardingState == .completed(plugin: .wcPayOnly) || onboardingState == .completed(plugin: .wcPayPreferred) } - .assign(to: &$isEligible) + .assign(to: &$isEligibleValue) } func isEligibleFromSiteChecks() -> Bool { diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index b70ec860876..13344ef6362 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -68,7 +68,7 @@ final class HubMenuViewModel: ObservableObject { private let featureFlagService: FeatureFlagService private let generalAppSettings: GeneralAppSettingsStorage private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol - private let posEligibilityChecker: POSEligibilityChecker + private let posEligibilityChecker: POSEligibilityCheckerProtocol private(set) var productReviewFromNoteParcel: ProductReviewFromNoteParcel? @@ -145,7 +145,7 @@ final class HubMenuViewModel: ObservableObject { } private func setupPOSElement() { - Publishers.CombineLatest(generalAppSettings.betaFeatureEnabledPublisher(.pointOfSale), posEligibilityChecker.$isEligible) + Publishers.CombineLatest(generalAppSettings.betaFeatureEnabledPublisher(.pointOfSale), posEligibilityChecker.isEligible) .map { isBetaFeatureEnabled, isEligibleForPOS in if isBetaFeatureEnabled && isEligibleForPOS { return PointOfSaleEntryPoint() From 31d8342cd6bbedf04a63e25f34972d65edc073f0 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 14:56:25 +0800 Subject: [PATCH 06/14] Refresh card payment onboarding once before observing onboarding state. --- WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 13344ef6362..a3f09f9727a 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -145,6 +145,7 @@ final class HubMenuViewModel: ObservableObject { } private func setupPOSElement() { + cardPresentPaymentsOnboarding.refreshIfNecessary() Publishers.CombineLatest(generalAppSettings.betaFeatureEnabledPublisher(.pointOfSale), posEligibilityChecker.isEligible) .map { isBetaFeatureEnabled, isEligibleForPOS in if isBetaFeatureEnabled && isEligibleForPOS { From 2bff92c64b841bc33c6493a8a213938661806c59 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 15:57:51 +0800 Subject: [PATCH 07/14] Move `MockInMemoryStorage` to be sharable. --- .../WooCommerce.xcodeproj/project.pbxproj | 4 +++ .../Mocks/MockInMemoryStorage.swift | 24 ++++++++++++++++++ .../Model/BetaFeaturesTests.swift | 25 +------------------ 3 files changed, 29 insertions(+), 24 deletions(-) create mode 100644 WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 6fe8a78cf74..c9666deb702 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -171,6 +171,7 @@ 023A059B24135F2600E3FC99 /* ReviewsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 023A059924135F2600E3FC99 /* ReviewsViewController.xib */; }; 023B3EBA2A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */; }; 023BD5842BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */; }; + 023BD5882BFDCF3100A10D7B /* MockInMemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */; }; 023D1DD124AB2D05002B03A3 /* ProductListSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */; }; 023D692E2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */; }; 023D69442588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */; }; @@ -2947,6 +2948,7 @@ 023A059924135F2600E3FC99 /* ReviewsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReviewsViewController.xib; sourceTree = ""; }; 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAnnouncementViewModelTests.swift; sourceTree = ""; }; 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesConfigurationViewModel.swift; sourceTree = ""; }; + 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockInMemoryStorage.swift; sourceTree = ""; }; 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListSelectorViewController.swift; sourceTree = ""; }; 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommand.swift; sourceTree = ""; }; 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommandTests.swift; sourceTree = ""; }; @@ -8743,6 +8745,7 @@ EEFF47312B309221009BF302 /* MockStoreCreationStoreSwitchScheduler.swift */, 02D635782B58071C00B1CBF6 /* MockNote.swift */, EE8A302A2B70B63E001D7C66 /* MockImageService.swift */, + 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */, ); path = Mocks; sourceTree = ""; @@ -15563,6 +15566,7 @@ E1C6535C27BD1D0A003E87D4 /* CardPresentConfigurationLoaderTests.swift in Sources */, 2688644325D471C700821BA5 /* EditAttributesViewModelTests.swift in Sources */, 8697AFBF2B622DEA00EFAF21 /* BlazeAddParameterViewModelTests.swift in Sources */, + 023BD5882BFDCF3100A10D7B /* MockInMemoryStorage.swift in Sources */, DE001323279A793A00EB0350 /* CouponWooTests.swift in Sources */, 45B98E1F25DECC1C00A1232B /* ShippingLabelAddressFormViewModelTests.swift in Sources */, 028E1F722833E954001F8829 /* DashboardViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift b/WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift new file mode 100644 index 00000000000..d5283e83d3e --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockInMemoryStorage.swift @@ -0,0 +1,24 @@ +import Storage + +final class MockInMemoryStorage: FileStorage { + private(set) var data: [URL: Codable] = [:] + + func data(for fileURL: URL) throws -> T where T: Decodable { + guard let data = data[fileURL] as? T else { + throw Errors.readFailed + } + return data + } + + func write(_ data: T, to fileURL: URL) throws where T: Encodable { + self.data[fileURL] = data as? Codable + } + + func deleteFile(at fileURL: URL) throws { + data.removeValue(forKey: fileURL) + } + + enum Errors: Error { + case readFailed + } +} diff --git a/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift b/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift index bc8f501a5fd..bbf9ec73929 100644 --- a/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift +++ b/WooCommerce/WooCommerceTests/Model/BetaFeaturesTests.swift @@ -2,7 +2,7 @@ import XCTest import Storage @testable import WooCommerce -class BetaFeaturesTests: XCTestCase { +final class BetaFeaturesTests: XCTestCase { var appSettings: GeneralAppSettingsStorage! override func setUpWithError() throws { @@ -32,26 +32,3 @@ class BetaFeaturesTests: XCTestCase { XCTAssertEqual(enabled, true) } } - -private final class MockInMemoryStorage: FileStorage { - private(set) var data: [URL: Codable] = [:] - - func data(for fileURL: URL) throws -> T where T: Decodable { - guard let data = data[fileURL] as? T else { - throw Errors.readFailed - } - return data - } - - func write(_ data: T, to fileURL: URL) throws where T: Encodable { - self.data[fileURL] = data as? Codable - } - - func deleteFile(at fileURL: URL) throws { - data.removeValue(forKey: fileURL) - } - - enum Errors: Error { - case readFailed - } -} From 0e2c11cb42280549dd43118ad49936b5f4fca8f2 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 15:58:32 +0800 Subject: [PATCH 08/14] DI `UIUserInterfaceIdiom` to `POSEligibilityChecker` for unit testing, with minor comment updates. --- .../Settings/POS/POSEligibilityChecker.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index 5070fb9fc9b..d947790e1eb 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -20,15 +20,18 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { @Published private var isEligibleValue: Bool = false private var onboardingStateSubscription: AnyCancellable? + private let userInterfaceIdiom: UIUserInterfaceIdiom private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol private let siteSettings: SelectedSiteSettings private let currencySettings: CurrencySettings private let featureFlagService: FeatureFlagService - init(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), + init(userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, + cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, currencySettings: CurrencySettings = ServiceLocator.currencySettings, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { + self.userInterfaceIdiom = userInterfaceIdiom self.siteSettings = siteSettings self.currencySettings = currencySettings self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding @@ -40,8 +43,8 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { private extension POSEligibilityChecker { /// Returns whether the selected store is eligible for POS. func observeOnboardingStateForEligibilityCheck() { - // Conditions that are fixed per lifetime of the menu tab. - let isTablet = UIDevice.current.userInterfaceIdiom == .pad + // Conditions that are fixed for its lifetime. + let isTablet = userInterfaceIdiom == .pad let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) guard isTablet && isFeatureFlagEnabled else { isEligibleValue = false @@ -60,7 +63,7 @@ private extension POSEligibilityChecker { } func isEligibleFromSiteChecks() -> Bool { - // Conditions that can change if site settings are synced during the lifetime of the menu tab. + // Conditions that can change if site settings are synced during the lifetime. let isCountryCodeUS = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode == .US let isCurrencyUSD = currencySettings.currencyCode == .USD return isCountryCodeUS && isCurrencyUSD From 61b04d92e8d970d90a625d0a7ebdbdcdde6295f3 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 15:59:11 +0800 Subject: [PATCH 09/14] Add basic test cases for `BetaFeaturesConfigurationViewModel` and `POSEligibilityChecker`. --- .../WooCommerce.xcodeproj/project.pbxproj | 16 ++ .../Mocks/MockFeatureFlagService.swift | 7 +- ...aFeaturesConfigurationViewModelTests.swift | 58 ++++++ .../POS/POSEligibilityCheckerTests.swift | 185 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index c9666deb702..364827ac13c 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -171,7 +171,9 @@ 023A059B24135F2600E3FC99 /* ReviewsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 023A059924135F2600E3FC99 /* ReviewsViewController.xib */; }; 023B3EBA2A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */; }; 023BD5842BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */; }; + 023BD5862BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5852BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift */; }; 023BD5882BFDCF3100A10D7B /* MockInMemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */; }; + 023BD58B2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */; }; 023D1DD124AB2D05002B03A3 /* ProductListSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */; }; 023D692E2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */; }; 023D69442588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */; }; @@ -2948,7 +2950,9 @@ 023A059924135F2600E3FC99 /* ReviewsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReviewsViewController.xib; sourceTree = ""; }; 023B3EB92A4E73BD003A720A /* LocalAnnouncementViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAnnouncementViewModelTests.swift; sourceTree = ""; }; 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesConfigurationViewModel.swift; sourceTree = ""; }; + 023BD5852BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesConfigurationViewModelTests.swift; sourceTree = ""; }; 023BD5872BFDCF3100A10D7B /* MockInMemoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockInMemoryStorage.swift; sourceTree = ""; }; + 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSEligibilityCheckerTests.swift; sourceTree = ""; }; 023D1DD024AB2D05002B03A3 /* ProductListSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListSelectorViewController.swift; sourceTree = ""; }; 023D692D2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommand.swift; sourceTree = ""; }; 023D69432588C6BD00F7DA72 /* ShippingLabelPaperSizeListSelectorCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaperSizeListSelectorCommandTests.swift; sourceTree = ""; }; @@ -6004,6 +6008,14 @@ path = Domains; sourceTree = ""; }; + 023BD5892BFDCF9500A10D7B /* POS */ = { + isa = PBXGroup; + children = ( + 023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */, + ); + path = POS; + sourceTree = ""; + }; 023D69BA2589BF2500F7DA72 /* Refund Shipping Label */ = { isa = PBXGroup; children = ( @@ -9760,6 +9772,7 @@ EE0EE7A728B74EF300F6061E /* CustomHelpCenterContentTests.swift */, 2631D4F929ED108400F13F20 /* WPComPlanNameSanitizer.swift */, B98FF43F2AAA096200326D16 /* AddressWooTests.swift */, + 023BD5852BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift */, ); path = Model; sourceTree = ""; @@ -10068,6 +10081,7 @@ BA143222273662DE00E4B3AB /* Settings */ = { isa = PBXGroup; children = ( + 023BD5892BFDCF9500A10D7B /* POS */, BAFEF51D273C2151005F94CC /* SettingsViewModelTests.swift */, 02AA586528531D0E0068B6F0 /* CloseAccountCoordinatorTests.swift */, 2609797A2A13D2B500442249 /* Privacy */, @@ -15208,6 +15222,7 @@ B6440FB9292E74230012D506 /* AnalyticsHubTimeRangeSelectionTests.swift in Sources */, B57C5C9E21B80E8300FF82B2 /* SessionManager+Internal.swift in Sources */, 2667BFDB252E659A008099D4 /* MockOrderItem.swift in Sources */, + 023BD5862BFDCECF00A10D7B /* BetaFeaturesConfigurationViewModelTests.swift in Sources */, DEC51AA0274F9922009F3DF4 /* JCPJetpackInstallStepsViewModelTests.swift in Sources */, E1068058285C787100668B46 /* BetaFeaturesTests.swift in Sources */, EE570A8D2BF5EE78006BA026 /* MostActiveCouponsCardViewModelTests.swift in Sources */, @@ -15677,6 +15692,7 @@ CC923A1D2847A8E0008EEEBE /* OrderStatusListViewModelTests.swift in Sources */, 267C01D129E9B18E00FCC97B /* StorePlanSynchronizerTests.swift in Sources */, EE45E2C42A4A85350085F227 /* TooltipPresenterTests.swift in Sources */, + 023BD58B2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift in Sources */, 0388E1A629E04433007DF84D /* PaymentsRouteTests.swift in Sources */, EEC5C8DA2ADE2FD80071E852 /* BlazeCampaignDashboardViewModelTests.swift in Sources */, D85B833D2230DC9D002168F3 /* StringWooTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 69dd5cf32c1..f72eea4e696 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -23,6 +23,7 @@ struct MockFeatureFlagService: FeatureFlagService { private let sideBySideViewForOrderForm: Bool private let isCustomersInHubMenuEnabled: Bool private let isSubscriptionsInOrderCreationCustomersEnabled: Bool + private let isDisplayPointOfSaleToggleEnabled: Bool init(isInboxOn: Bool = false, isUpdateOrderOptimisticallyOn: Bool = false, @@ -44,7 +45,8 @@ struct MockFeatureFlagService: FeatureFlagService { isBackendReceiptsEnabled: Bool = false, sideBySideViewForOrderForm: Bool = false, isCustomersInHubMenuEnabled: Bool = false, - isSubscriptionsInOrderCreationCustomersEnabled: Bool = false) { + isSubscriptionsInOrderCreationCustomersEnabled: Bool = false, + isDisplayPointOfSaleToggleEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn self.shippingLabelsOnboardingM1 = shippingLabelsOnboardingM1 @@ -66,6 +68,7 @@ struct MockFeatureFlagService: FeatureFlagService { self.sideBySideViewForOrderForm = sideBySideViewForOrderForm self.isCustomersInHubMenuEnabled = isCustomersInHubMenuEnabled self.isSubscriptionsInOrderCreationCustomersEnabled = isSubscriptionsInOrderCreationCustomersEnabled + self.isDisplayPointOfSaleToggleEnabled = isDisplayPointOfSaleToggleEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -110,6 +113,8 @@ struct MockFeatureFlagService: FeatureFlagService { return isCustomersInHubMenuEnabled case .subscriptionsInOrderCreationCustomers: return isSubscriptionsInOrderCreationCustomersEnabled + case .displayPointOfSaleToggle: + return isDisplayPointOfSaleToggleEnabled default: return false } diff --git a/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift b/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift new file mode 100644 index 00000000000..7a7c22bf6c8 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift @@ -0,0 +1,58 @@ +import Combine +import Storage +import XCTest +@testable import WooCommerce + +final class BetaFeaturesConfigurationViewModelTests: XCTestCase { + private var appSettings: GeneralAppSettingsStorage! + + override func setUpWithError() throws { + appSettings = GeneralAppSettingsStorage.init(fileStorage: MockInMemoryStorage()) + } + + override func tearDownWithError() throws { + appSettings = nil + } + + func test_availableFeatures_include_viewAddOns() { + // Given + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, + posEligibilityChecker: MockPOSEligibilityChecker(isEligibleValue: true)) + + // Then + XCTAssertTrue(viewModel.availableFeatures.contains(.viewAddOns)) + } + + func test_availableFeatures_include_pos_when_eligible_for_pos() { + // Given + let posEligibilityChecker = MockPOSEligibilityChecker(isEligibleValue: true) + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, posEligibilityChecker: posEligibilityChecker) + + // Then + XCTAssertTrue(viewModel.availableFeatures.contains(.pointOfSale)) + } + + func test_availableFeatures_do_not_include_pos_when_not_eligible_for_pos_anymore() { + // Given + let posEligibilityChecker = MockPOSEligibilityChecker(isEligibleValue: true) + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, posEligibilityChecker: posEligibilityChecker) + + // When + posEligibilityChecker.isEligibleValue = false + + // Then + XCTAssertFalse(viewModel.availableFeatures.contains(.pointOfSale)) + } +} + +private final class MockPOSEligibilityChecker: POSEligibilityCheckerProtocol { + @Published var isEligibleValue: Bool + + init(isEligibleValue: Bool) { + self.isEligibleValue = isEligibleValue + } + + var isEligible: AnyPublisher { + $isEligibleValue.eraseToAnyPublisher() + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift new file mode 100644 index 00000000000..ce16f0071e3 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift @@ -0,0 +1,185 @@ +import Combine +import WooFoundation +import XCTest +import Yosemite +@testable import WooCommerce + +final class POSEligibilityCheckerTests: XCTestCase { + private var onboardingUseCase: MockCardPresentPaymentsOnboardingUseCase! + private var stores: MockStoresManager! + private var storageManager: MockStorageManager! + private var siteSettings: SelectedSiteSettings! + @Published private var isEligible: Bool = false + + private let siteID: Int64 = 2 + + override func setUp() { + super.setUp() + onboardingUseCase = MockCardPresentPaymentsOnboardingUseCase(initial: .completed(plugin: .wcPayPreferred)) + stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + stores.updateDefaultStore(storeID: siteID) + storageManager = MockStorageManager() + siteSettings = SelectedSiteSettings(stores: stores, storageManager: storageManager) + } + + override func tearDown() { + siteSettings = nil + storageManager = nil + stores = nil + onboardingUseCase = nil + super.tearDown() + } + + func test_site_with_all_conditions_is_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertTrue(isEligible) + } + + func test_non_iPad_devices_are_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] + .forEach { userInterfaceIdiom in + let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + } + + func test_non_us_site_is_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + [Country.ca, Country.es, Country.gb].forEach { country in + // When + setupCountry(country: country) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + } + + func test_non_usd_site_is_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.nonUSDCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_not_eligible_when_feature_flag_is_disabled() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: false) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + + // Then + XCTAssertFalse(isEligible) + } + + func test_is_not_eligible_when_onboarding_state_is_not_completed_wcpay() throws { + // Given + let featureFlagService = MockFeatureFlagService(isDisplayPointOfSaleToggleEnabled: true) + setupCountry(country: .us) + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + cardPresentPaymentsOnboarding: onboardingUseCase, + siteSettings: siteSettings, + currencySettings: Fixtures.usdCurrencySettings, + featureFlagService: featureFlagService) + checker.isEligible.assign(to: &$isEligible) + XCTAssertTrue(isEligible) + + // When onboarding state is loading + onboardingUseCase.state = .loading + // Then + XCTAssertFalse(isEligible) + + // When onboarding state is stripeOnly + onboardingUseCase.state = .completed(plugin: .stripeOnly) + // Then + XCTAssertFalse(isEligible) + + // When onboarding state is wcPayOnly + onboardingUseCase.state = .completed(plugin: .wcPayOnly) + // Then + XCTAssertTrue(isEligible) + + // When onboarding state is stripePreferred + onboardingUseCase.state = .completed(plugin: .stripePreferred) + // Then + XCTAssertFalse(isEligible) + + // When onboarding state is pluginNotInstalled + onboardingUseCase.state = .pluginNotInstalled + // Then + XCTAssertFalse(isEligible) + } +} + +private extension POSEligibilityCheckerTests { + func setupCountry(country: Country) { + let setting = SiteSetting.fake() + .copy( + siteID: siteID, + settingID: "woocommerce_default_country", + value: country.rawValue, + settingGroupKey: SiteSettingGroup.general.rawValue + ) + storageManager.insertSampleSiteSetting(readOnlySiteSetting: setting) + siteSettings.refresh() + } + + enum Fixtures { + static let usdCurrencySettings = CurrencySettings(currencyCode: .USD, + currencyPosition: .leftSpace, + thousandSeparator: "", + decimalSeparator: ".", + numberOfDecimals: 3) + static let nonUSDCurrencySettings = CurrencySettings(currencyCode: .CAD, + currencyPosition: .leftSpace, + thousandSeparator: "", + decimalSeparator: ".", + numberOfDecimals: 3) + } + + enum Country: String { + case us = "US:CA" + case ca = "CA:NS" + case gb = "GB" + case es = "ES" + } +} From 59c1682a2d4f7550e44535f7e29c2da7a3992366 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 16:03:06 +0800 Subject: [PATCH 10/14] Remove unused variable. --- .../Dashboard/Settings/POS/POSEligibilityChecker.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index d947790e1eb..d3251df9bf9 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -18,7 +18,6 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { } @Published private var isEligibleValue: Bool = false - private var onboardingStateSubscription: AnyCancellable? private let userInterfaceIdiom: UIUserInterfaceIdiom private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol From 8352ec812a602ed70aa8547b7d4d18e962c7bb5b Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 16:34:07 +0800 Subject: [PATCH 11/14] Reuse `CardPresentPaymentsOnboardingUseCase` with Payments menu for shared onboarding state. --- WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index a3f09f9727a..142d5717aa7 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -96,7 +96,7 @@ final class HubMenuViewModel: ObservableObject { siteID: siteID, dependencies: .init( cardPresentPaymentsConfiguration: CardPresentConfigurationLoader().configuration, - onboardingUseCase: CardPresentPaymentsOnboardingUseCase(), + onboardingUseCase: cardPresentPaymentsOnboarding, cardReaderSupportDeterminer: CardReaderSupportDeterminer(siteID: siteID), wooPaymentsDepositService: WooPaymentsDepositService(siteID: siteID, credentials: ServiceLocator.stores.sessionManager.defaultCredentials!)), From fd4d561e80122ec545a3a1960508212b5b4beca0 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 16:34:43 +0800 Subject: [PATCH 12/14] Set up POS observation in view appear to reflect on site settings changes. --- WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 142d5717aa7..e5614da69dd 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -123,7 +123,6 @@ final class HubMenuViewModel: ObservableObject { featureFlagService: featureFlagService) observeSiteForUIUpdates() observePlanName() - setupPOSElement() tapToPayBadgePromotionChecker.$shouldShowTapToPayBadges.share().assign(to: &$shouldShowNewFeatureBadgeOnPayments) } @@ -134,6 +133,7 @@ final class HubMenuViewModel: ObservableObject { /// Resets the menu elements displayed on the menu. /// func setupMenuElements() { + setupPOSElement() setupSettingsElements() setupGeneralElements() } From 94aebf7ed365797f53da1729f1817a970fec64a6 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 22 May 2024 16:47:52 +0800 Subject: [PATCH 13/14] Revert "Reuse `CardPresentPaymentsOnboardingUseCase` with Payments menu for shared onboarding state." This reverts commit 8352ec812a602ed70aa8547b7d4d18e962c7bb5b. --- WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index e5614da69dd..24a3bc54a99 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -96,7 +96,7 @@ final class HubMenuViewModel: ObservableObject { siteID: siteID, dependencies: .init( cardPresentPaymentsConfiguration: CardPresentConfigurationLoader().configuration, - onboardingUseCase: cardPresentPaymentsOnboarding, + onboardingUseCase: CardPresentPaymentsOnboardingUseCase(), cardReaderSupportDeterminer: CardReaderSupportDeterminer(siteID: siteID), wooPaymentsDepositService: WooPaymentsDepositService(siteID: siteID, credentials: ServiceLocator.stores.sessionManager.defaultCredentials!)), From 7221165f6db4d49bf81cf22783e194648333902b Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 23 May 2024 18:16:21 +0800 Subject: [PATCH 14/14] Move `onAppear` from `NavigationStack` that hosts the navigation destinations to `menuList` view itself. --- WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift index d5e595c1f26..e16ed32f19d 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift @@ -34,9 +34,9 @@ struct HubMenu: View { .navigationDestination(isPresented: $viewModel.showingCoupons) { couponListView } - } - .onAppear { - viewModel.setupMenuElements() + .onAppear { + viewModel.setupMenuElements() + } } }