From 6677a7470be8e9283216ddb20b56ba7c7f806277 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 13:13:16 +0800 Subject: [PATCH 01/20] Add feature flag for POS as a tab i1. --- Modules/Sources/Experiments/DefaultFeatureFlagService.swift | 2 ++ Modules/Sources/Experiments/FeatureFlag.swift | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift index ba00fab13bd..6018c8ae409 100644 --- a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift +++ b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift @@ -112,6 +112,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { case .showPointOfSaleBarcodeSimulator: // Enables a simulated barcode scanner in dev builds for testing. Do not ship this one! return buildConfig == .localDeveloper || buildConfig == .alpha + case .pointOfSaleAsATabi1: + return buildConfig == .localDeveloper || buildConfig == .alpha default: return true } diff --git a/Modules/Sources/Experiments/FeatureFlag.swift b/Modules/Sources/Experiments/FeatureFlag.swift index df6847bcc01..75cc31ff55b 100644 --- a/Modules/Sources/Experiments/FeatureFlag.swift +++ b/Modules/Sources/Experiments/FeatureFlag.swift @@ -236,4 +236,8 @@ public enum FeatureFlag: Int { /// Enables a simulated barcode scanner for testing in POS. Do not ship this one! /// case showPointOfSaleBarcodeSimulator + + /// Enables displaying POS as a tab in the tab bar with the same eligibility as the previous entry point + /// + case pointOfSaleAsATabi1 } From 215fcb576a9a2a24b6f51d85d1b958fc554cbbd6 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 13:22:33 +0800 Subject: [PATCH 02/20] POSEligibilityChecker: DI `siteID` in initializer and remove unused use case in `BetaFeaturesConfigurationViewModel`. --- .../BetaFeaturesConfigurationViewModel.swift | 38 +++++-------------- .../Settings/POS/POSEligibilityChecker.swift | 11 +++--- .../Hub Menu/HubMenuViewModel.swift | 3 +- ...aFeaturesConfigurationViewModelTests.swift | 15 +------- 4 files changed, 18 insertions(+), 49 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift index 3ea7649270f..3e8d9d16185 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift @@ -6,42 +6,22 @@ final class BetaFeaturesConfigurationViewModel: ObservableObject { @Published private(set) var availableFeatures: [BetaFeature] = [] private let appSettings: GeneralAppSettingsStorage private let featureFlagService: FeatureFlagService - private let posEligibilityChecker: POSEligibilityCheckerProtocol init(appSettings: GeneralAppSettingsStorage = ServiceLocator.generalAppSettings, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - posEligibilityChecker: POSEligibilityCheckerProtocol = POSEligibilityChecker( - siteSettings: ServiceLocator.selectedSiteSettings, - currencySettings: ServiceLocator.currencySettings, - featureFlagService: ServiceLocator.featureFlagService - )) { + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { self.appSettings = appSettings self.featureFlagService = featureFlagService - self.posEligibilityChecker = posEligibilityChecker - observePOSEligibilityForAvailableFeatures() + availableFeatures = BetaFeature.allCases.filter { betaFeature in + switch betaFeature { + case .viewAddOns: + return true + case .inAppPurchases: + return featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu) + } + } } 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) - } - } - } - .assign(to: &$availableFeatures) - } -} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index 77050421bdf..e3720847373 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -34,17 +34,20 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { .eraseToAnyPublisher() } + private let siteID: Int64 private let userInterfaceIdiom: UIUserInterfaceIdiom private let siteSettings: SelectedSiteSettings private let currencySettings: CurrencySettings private let stores: StoresManager private let featureFlagService: FeatureFlagService - init(userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, + init(siteID: Int64, + userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, currencySettings: CurrencySettings = ServiceLocator.currencySettings, stores: StoresManager = ServiceLocator.stores, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { + self.siteID = siteID self.userInterfaceIdiom = userInterfaceIdiom self.siteSettings = siteSettings self.currencySettings = currencySettings @@ -56,8 +59,7 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { private extension POSEligibilityChecker { var isWooCommerceVersionSupported: AnyPublisher<(isSupported: Bool, wcVersion: String?), Never> { Future<(isSupported: Bool, wcVersion: String?), Never> { [weak self] promise in - guard let self, - let siteID = self.stores.sessionManager.defaultStoreID else { + guard let self else { promise(.success((isSupported: false, wcVersion: nil))) return } @@ -83,8 +85,7 @@ private extension POSEligibilityChecker { .flatMap { [weak self] isSupported, wcVersion -> AnyPublisher in guard let self, isSupported, - let wcVersion, - let siteID = self.stores.sessionManager.defaultStoreID else { + let wcVersion else { return Just(false).eraseToAnyPublisher() } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 148601bfd1d..58e751168e2 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -178,7 +178,8 @@ final class HubMenuViewModel: ObservableObject { self.blazeEligibilityChecker = blazeEligibilityChecker self.googleAdsEligibilityChecker = googleAdsEligibilityChecker self.cardPresentPaymentsOnboarding = CardPresentPaymentsOnboardingUseCase() - self.posEligibilityChecker = POSEligibilityChecker(siteSettings: ServiceLocator.selectedSiteSettings, + self.posEligibilityChecker = POSEligibilityChecker(siteID: siteID, + siteSettings: ServiceLocator.selectedSiteSettings, currencySettings: ServiceLocator.currencySettings, featureFlagService: featureFlagService) self.analytics = analytics diff --git a/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift b/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift index 747726a67ee..7d790a7e153 100644 --- a/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/Model/BetaFeaturesConfigurationViewModelTests.swift @@ -16,22 +16,9 @@ final class BetaFeaturesConfigurationViewModelTests: XCTestCase { func test_availableFeatures_include_viewAddOns() { // Given - let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings, - posEligibilityChecker: MockPOSEligibilityChecker(isEligibleValue: true)) + let viewModel = BetaFeaturesConfigurationViewModel(appSettings: appSettings) // Then XCTAssertTrue(viewModel.availableFeatures.contains(.viewAddOns)) } } - -private final class MockPOSEligibilityChecker: POSEligibilityCheckerProtocol { - @Published var isEligibleValue: Bool - - init(isEligibleValue: Bool) { - self.isEligibleValue = isEligibleValue - } - - var isEligible: AnyPublisher { - $isEligibleValue.eraseToAnyPublisher() - } -} From 813923a630f14df81195c2cac2202a4e56582c55 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 14:09:22 +0800 Subject: [PATCH 03/20] Initial implementation of POS tab based on the existing POS eligibility. --- .../POS/TabBar/POSTabCoordinator.swift | 149 ++++++++++++++++++ .../ViewRelated/MainTabBarController.swift | 122 ++++++++++---- .../ViewRelated/TabBar/WooTab+Tag.swift | 4 +- .../WooCommerce.xcodeproj/project.pbxproj | 12 ++ 4 files changed, 256 insertions(+), 31 deletions(-) create mode 100644 WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift new file mode 100644 index 00000000000..7b05733f5ee --- /dev/null +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -0,0 +1,149 @@ +import Foundation +import UIKit +import SwiftUI +import Yosemite + +final class POSTabViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + // TODO: localize and move to SwiftUI if feasible + title = "Point of Sale" + tabBarItem.title = title + tabBarItem.image = .creditCardImage + tabBarItem.accessibilityIdentifier = "tab-bar-pos-item" + } +} + +/// Coordinator for the Point of Sale tab. +/// +final class POSTabCoordinator { + private let siteID: Int64 + private let tabContainerController: TabContainerController + private let viewControllerToPresent: UIViewController + private let storesManager: StoresManager + private let posEligibilityChecker: POSEligibilityCheckerProtocol + private let credentials: Credentials? + + private(set) lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { + PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials) + }() + + private(set) lazy var posPopularItemFetchStrategyFactory: PointOfSaleFixedItemFetchStrategyFactory = { + PointOfSaleFixedItemFetchStrategyFactory(fixedStrategy: posItemFetchStrategyFactory.popularStrategy()) + }() + + private(set) lazy var posCouponFetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory = { + PointOfSaleCouponFetchStrategyFactory(siteID: siteID, + currencySettings: ServiceLocator.currencySettings, + credentials: credentials, + // TODO: DI + storage: ServiceLocator.storageManager) + }() + + private(set) lazy var posCouponProvider: PointOfSaleCouponServiceProtocol = { + let storage = ServiceLocator.storageManager + let currencySettings = ServiceLocator.currencySettings + + return PointOfSaleCouponService(siteID: siteID, + currencySettings: currencySettings, + credentials: credentials, + storage: storage) + }() + + private(set) lazy var barcodeScanService: PointOfSaleBarcodeScanService = { + PointOfSaleBarcodeScanService(siteID: siteID, + credentials: credentials, + // TODO: DI + currencySettings: ServiceLocator.currencySettings) + }() + + init(siteID: Int64, + tabContainerController: TabContainerController, + viewControllerToPresent: UIViewController, + storesManager: StoresManager = ServiceLocator.stores, + posEligibilityChecker: POSEligibilityCheckerProtocol) { + self.siteID = siteID + self.storesManager = storesManager + self.posEligibilityChecker = posEligibilityChecker + self.tabContainerController = tabContainerController + self.viewControllerToPresent = viewControllerToPresent + self.credentials = storesManager.sessionManager.defaultCredentials + + tabContainerController.wrappedController = POSTabViewController() + } + + func onTabSelected() { + presentPOSView() + } +} + +private extension POSTabCoordinator { + func presentPOSView() { + Task { @MainActor [weak self] in + guard let self else { return } + let collectOrderPaymentAnalyticsTracker = POSCollectOrderPaymentAnalytics() + let cardPresentPaymentService = await CardPresentPaymentService(siteID: siteID, + collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker) + if let receiptService = POSReceiptService(siteID: siteID, + credentials: credentials), + let orderService = POSOrderService(siteID: siteID, + credentials: credentials), + #available(iOS 17.0, *) { + let posView = PointOfSaleEntryPointView( + itemsController: PointOfSaleItemsController( + itemProvider: PointOfSaleItemService( + currencySettings: ServiceLocator.currencySettings), + itemFetchStrategyFactory: posItemFetchStrategyFactory), + purchasableItemsSearchController: PointOfSaleItemsController( + itemProvider: PointOfSaleItemService( + currencySettings: ServiceLocator.currencySettings), + itemFetchStrategyFactory: posItemFetchStrategyFactory, + initialState: .init(containerState: .content, + itemsStack: .init(root: .loaded([], hasMoreItems: true), itemStates: [:]))), + couponsController: PointOfSaleCouponsController(itemProvider: posCouponProvider, + fetchStrategyFactory: posCouponFetchStrategyFactory), + couponsSearchController: PointOfSaleCouponsController(itemProvider: posCouponProvider, + fetchStrategyFactory: posCouponFetchStrategyFactory), + onPointOfSaleModeActiveStateChange: { [weak self] isEnabled in + self?.updateDefaultConfigurationForPointOfSale(isEnabled) + }, + cardPresentPaymentService: cardPresentPaymentService, + orderController: PointOfSaleOrderController(orderService: orderService, + receiptService: receiptService), + collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker, + searchHistoryService: POSSearchHistoryService(siteID: siteID), + popularPurchasableItemsController: PointOfSaleItemsController( + itemProvider: PointOfSaleItemService(currencySettings: ServiceLocator.currencySettings), + itemFetchStrategyFactory: posPopularItemFetchStrategyFactory + ), + barcodeScanService: barcodeScanService + ) + let hostingController = UIHostingController(rootView: posView) + hostingController.modalPresentationStyle = .fullScreen + viewControllerToPresent.present(hostingController, animated: true) + } + } + } +} + +private extension POSTabCoordinator { + func updateDefaultConfigurationForPointOfSale(_ isPointOfSaleActive: Bool) { + updateInAppNotifications(isPointOfSaleActive) + updateTrackEventPrefix(isPointOfSaleActive) + } + + /// Disables foreground in-app notifications when Point of Sale is active. + func updateInAppNotifications(_ isPointOfSaleActive: Bool) { + if isPointOfSaleActive { + ServiceLocator.pushNotesManager.disableInAppNotifications() + } else { + ServiceLocator.pushNotesManager.enableInAppNotifications() + } + } + + /// Decorates track events with a different prefix when Point of Sale is active. + func updateTrackEventPrefix(_ isPointOfSaleActive: Bool) { + TracksProvider.setPOSMode(isPointOfSaleActive) + } +} diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index f7b22a88d4c..a9ca50c0c8e 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -22,6 +22,10 @@ enum WooTab { /// case products + /// Point of Sale Tab + /// + case pointOfSale + /// Hub Menu Tab /// case hubMenu @@ -32,14 +36,16 @@ extension WooTab { /// /// - Parameters: /// - visibleIndex: the index of visible tabs on the tab bar - init(visibleIndex: Int) { - let tabs = WooTab.visibleTabs() + /// - isPOSTabVisible: indicates if the Point of Sale tab is visible. + init(visibleIndex: Int, isPOSTabVisible: Bool) { + let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) self = tabs[visibleIndex] } /// Returns the visible tab index. - func visibleIndex() -> Int { - let tabs = WooTab.visibleTabs() + /// - Parameter isPOSTabVisible: indicates if the Point of Sale tab is visible. + func visibleIndex(isPOSTabVisible: Bool) -> Int { + let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) guard let tabIndex = tabs.firstIndex(where: { $0 == self }) else { assertionFailure("Trying to get the visible tab index for tab \(self) while the visible tabs are: \(tabs)") return 0 @@ -47,9 +53,16 @@ extension WooTab { return tabIndex } - // Note: currently only the Dashboard tab (My Store) view controller is set up in Main.storyboard. - private static func visibleTabs() -> [WooTab] { - [.myStore, .orders, .products, .hubMenu] + /// Note: currently only the Dashboard tab (My Store) view controller is set up in Main.storyboard. + /// + /// - Parameter isPOSTabVisible: indicates if the Point of Sale tab is visible. + /// - Returns: visible tabs in the tab bar. + static func visibleTabs(isPOSTabVisible: Bool) -> [WooTab] { + if isPOSTabVisible { + return [.myStore, .orders, .products, .pointOfSale, .hubMenu] + } else { + return [.myStore, .orders, .products, .hubMenu] + } } } @@ -97,7 +110,9 @@ final class MainTabBarController: UITabBarController { /// remove when .splitViewInProductsTab is removed. private let productsNavigationController = WooTabNavigationController() - private let reviewsNavigationController = WooTabNavigationController() + private let posContainerController = TabContainerController() + private var posTabCoordinator: POSTabCoordinator? + private let hubMenuContainerController = TabContainerController() private var hubMenuTabCoordinator: HubMenuCoordinator? @@ -110,6 +125,11 @@ final class MainTabBarController: UITabBarController { private var productImageUploadErrorsSubscription: AnyCancellable? + private var posEligibilityChecker: POSEligibilityChecker? + private var posEligibilitySubscription: AnyCancellable? + + private var isPOSTabVisible: Bool = false + private lazy var isProductsSplitViewFeatureFlagOn = featureFlagService.isFeatureFlagEnabled(.splitViewInProductsTab) init?(coder: NSCoder, @@ -134,6 +154,7 @@ final class MainTabBarController: UITabBarController { deinit { cancellableSiteID?.cancel() + posEligibilitySubscription?.cancel() } // MARK: - Overridden Methods @@ -142,14 +163,16 @@ final class MainTabBarController: UITabBarController { super.viewDidLoad() setNeedsStatusBarAppearanceUpdate() // call this to refresh status bar changes happening at runtime + delegate = self + fixTabBarTraitCollectionOnIpadForiOS18() - configureTabViewControllers() + // POS tab is hidden by default. + updateTabViewControllers(isPOSTabVisible: false) observeSiteIDForViewControllers() observeProductImageUploadStatusUpdates() startListeningToHubMenuTabBadgeUpdates() - viewModel.loadHubMenuTabBadge() } override func viewWillAppear(_ animated: Bool) { @@ -170,11 +193,11 @@ final class MainTabBarController: UITabBarController { } override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - let currentlySelectedTab = WooTab(visibleIndex: selectedIndex) + let currentlySelectedTab = WooTab(visibleIndex: selectedIndex, isPOSTabVisible: isPOSTabVisible) guard let userSelectedIndex = tabBar.items?.firstIndex(of: item) else { return } - let userSelectedTab = WooTab(visibleIndex: userSelectedIndex) + let userSelectedTab = WooTab(visibleIndex: userSelectedIndex, isPOSTabVisible: isPOSTabVisible) // Did we reselect the already-selected tab? if currentlySelectedTab == userSelectedTab { @@ -204,7 +227,7 @@ final class MainTabBarController: UITabBarController { func navigateToTabWithViewController(_ tab: WooTab, animated: Bool = false, completion: ((UIViewController) -> Void)? = nil) { dismiss(animated: animated) { [weak self] in guard let self else { return } - selectedIndex = tab.visibleIndex() + selectedIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) guard let selectedViewController else { return } @@ -258,6 +281,17 @@ final class MainTabBarController: UITabBarController { } } +// MARK: - UITabBarControllerDelegate +// +extension MainTabBarController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + let isSelectingPOSTab = viewController == posContainerController + if isSelectingPOSTab { + posTabCoordinator?.onTabSelected() + } + return !isSelectingPOSTab + } +} // MARK: - UIViewControllerTransitioningDelegate // @@ -302,6 +336,8 @@ private extension MainTabBarController { event: .Products.productListSelected(horizontalSizeClass: UITraitCollection.current.horizontalSizeClass)) case .hubMenu: ServiceLocator.analytics.track(.hubMenuTabSelected) + case .pointOfSale: + // TODO: WOOMOB-571 - analytics break } } @@ -321,6 +357,9 @@ private extension MainTabBarController { case .hubMenu: ServiceLocator.analytics.track(.hubMenuTabReselected) break + case .pointOfSale: + // TODO: WOOMOB-571 - analytics + break } } } @@ -599,20 +638,31 @@ extension MainTabBarController: DeepLinkNavigator { // MARK: - Site ID observation for updating tab view controllers // private extension MainTabBarController { - func configureTabViewControllers() { - viewControllers = { - var controllers = [UIViewController]() - - let tabs: [WooTab] = [.myStore, .orders, .products, .hubMenu] - tabs.forEach { tab in - let tabIndex = tab.visibleIndex() - let tabViewController = rootTabViewController(tab: tab) - controllers.insert(tabViewController, at: tabIndex) + func observePOSEligibilityForPOSTabVisibility(siteID: Int64) { + // Hides POS tab initially. + updateTabViewControllers(isPOSTabVisible: false) + // Starts observing the POS eligibility state. + posEligibilitySubscription = posEligibilityChecker?.isEligible + .receive(on: DispatchQueue.main) + .sink { [weak self] isPOSTabVisible in + guard let self else { return } + updateTabViewControllers(isPOSTabVisible: isPOSTabVisible) + viewModel.loadHubMenuTabBadge() } - return controllers - }() } - + + func updateTabViewControllers(isPOSTabVisible: Bool) { + var controllers = [UIViewController]() + let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) + tabs.forEach { tab in + let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) + let tabViewController = rootTabViewController(tab: tab) + controllers.insert(tabViewController, at: tabIndex) + } + viewControllers = controllers + self.isPOSTabVisible = isPOSTabVisible + } + func rootTabViewController(tab: WooTab) -> UIViewController { switch tab { case .myStore: @@ -623,6 +673,8 @@ private extension MainTabBarController { return isProductsSplitViewFeatureFlagOn ? productsContainerController: productsNavigationController case .hubMenu: return hubMenuContainerController + case .pointOfSale: + return posContainerController } } @@ -657,6 +709,16 @@ private extension MainTabBarController { navigateToContent: { _ in })] } + // Configures POS tab coordinator once per logged in site session. + let posEligibilityChecker = POSEligibilityChecker(siteID: siteID) + self.posEligibilityChecker = posEligibilityChecker + posTabCoordinator = POSTabCoordinator( + siteID: siteID, + tabContainerController: posContainerController, + viewControllerToPresent: self, + posEligibilityChecker: posEligibilityChecker + ) + // Configure hub menu tab coordinator once per logged in session potentially with multiple sites. if hubMenuTabCoordinator == nil { let hubTabCoordinator = createHubMenuTabCoordinator() @@ -664,10 +726,10 @@ private extension MainTabBarController { } hubMenuTabCoordinator?.activate(siteID: siteID) - viewModel.loadHubMenuTabBadge() - // Set dashboard to be the default tab. - selectedIndex = WooTab.myStore.visibleIndex() + selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: isPOSTabVisible) + + observePOSEligibilityForPOSTabVisibility(siteID: siteID) } func createDashboardViewController(siteID: Int64) -> UIViewController { @@ -709,7 +771,7 @@ private extension MainTabBarController { func updateMenuTabBadge(with action: NotificationBadgeActionType) { let tab = WooTab.hubMenu - let tabIndex = tab.visibleIndex() + let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) let input = NotificationsBadgeInput(action: action, tab: tab, tabBar: self.tabBar, tabIndex: tabIndex) self.notificationsBadge.updateBadge(with: input) @@ -726,7 +788,7 @@ private extension MainTabBarController { } let tab = WooTab.orders - let tabIndex = tab.visibleIndex() + let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) guard let orderTab: UITabBarItem = self.tabBar.items?[tabIndex] else { return diff --git a/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift b/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift index ffb2ac972fe..2a192fd477d 100644 --- a/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift +++ b/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift @@ -10,8 +10,10 @@ extension WooTab { return 1 case .products: return 2 - case .hubMenu: + case .pointOfSale: return 3 + case .hubMenu: + return 4 } } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 457c6678834..8a2b3d4ea05 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -492,6 +492,7 @@ 02A9BCD62737F73C00159C79 /* JetpackBenefitItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9BCD52737F73C00159C79 /* JetpackBenefitItem.swift */; }; 02AA586628531D0E0068B6F0 /* CloseAccountCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AA586528531D0E0068B6F0 /* CloseAccountCoordinatorTests.swift */; }; 02AAD54525023A8300BA1E26 /* ProductFormRemoteActionUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AAD54425023A8300BA1E26 /* ProductFormRemoteActionUseCase.swift */; }; + 02ABF9BB2DF7F8F100348186 /* POSTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02ABF9BA2DF7F8EF00348186 /* POSTabCoordinator.swift */; }; 02AC30CF2888EC8100146A25 /* WooAnalyticsEvent+LoginOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AC30CE2888EC8100146A25 /* WooAnalyticsEvent+LoginOnboarding.swift */; }; 02AC822C2498BC9700A615FB /* ProductFormViewModel+UpdatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AC822B2498BC9700A615FB /* ProductFormViewModel+UpdatesTests.swift */; }; 02ACD25A2852E11700EC928E /* CloseAccountCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02ACD2592852E11700EC928E /* CloseAccountCoordinator.swift */; }; @@ -3743,6 +3744,7 @@ 02AA586528531D0E0068B6F0 /* CloseAccountCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseAccountCoordinatorTests.swift; sourceTree = ""; }; 02AAD54425023A8300BA1E26 /* ProductFormRemoteActionUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFormRemoteActionUseCase.swift; sourceTree = ""; }; 02AB82EB27069D5D008D7334 /* Experiments.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Experiments.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02ABF9BA2DF7F8EF00348186 /* POSTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabCoordinator.swift; sourceTree = ""; }; 02AC30CE2888EC8100146A25 /* WooAnalyticsEvent+LoginOnboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+LoginOnboarding.swift"; sourceTree = ""; }; 02AC822B2498BC9700A615FB /* ProductFormViewModel+UpdatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductFormViewModel+UpdatesTests.swift"; sourceTree = ""; }; 02ACD2592852E11700EC928E /* CloseAccountCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseAccountCoordinator.swift; sourceTree = ""; }; @@ -7605,6 +7607,7 @@ 026826912BF59D7A0036F959 /* ViewHelpers */, 02D1D2D82CD3CD710069A93F /* Analytics */, 2004E2C02C076CCA00D62521 /* Card Present Payments */, + 02ABF9B92DF7F8E200348186 /* TabBar */, 026826972BF59D9E0036F959 /* Utils */, ); path = POS; @@ -7666,6 +7669,14 @@ path = BottomSheetListSelector; sourceTree = ""; }; + 02ABF9B92DF7F8E200348186 /* TabBar */ = { + isa = PBXGroup; + children = ( + 02ABF9BA2DF7F8EF00348186 /* POSTabCoordinator.swift */, + ); + path = TabBar; + sourceTree = ""; + }; 02B1914A2CCF278100CF38C9 /* Payments Onboarding */ = { isa = PBXGroup; children = ( @@ -15848,6 +15859,7 @@ 01C9C59F2DA3D98400CD81D8 /* CartRowRemoveButton.swift in Sources */, 457509E4267B9E91005FA2EA /* AggregatedProductListViewController.swift in Sources */, D8815B0D263861A400EDAD62 /* CardPresentModalSuccess.swift in Sources */, + 02ABF9BB2DF7F8F100348186 /* POSTabCoordinator.swift in Sources */, CCE73D2529EDAB5C0064E797 /* SubscriptionPeriod+UI.swift in Sources */, 0235595524496B6D004BE2B8 /* BottomSheetListSelectorCommand.swift in Sources */, EE1905862B57BBE300617C53 /* BlazePaymentMethodsView.swift in Sources */, From 879db1d288c8191a6cfe60544e932c3c93d9c757 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 14:10:23 +0800 Subject: [PATCH 04/20] SelectedSiteSettings: pass self when posting notification so that the POS eligibility only receives notifications for the ServiceLocator instance. --- .../Tools/Shared Site Settings/SelectedSiteSettings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/Tools/Shared Site Settings/SelectedSiteSettings.swift b/WooCommerce/Classes/Tools/Shared Site Settings/SelectedSiteSettings.swift index d68f7fbfcf6..6ad356c6f36 100644 --- a/WooCommerce/Classes/Tools/Shared Site Settings/SelectedSiteSettings.swift +++ b/WooCommerce/Classes/Tools/Shared Site Settings/SelectedSiteSettings.swift @@ -71,7 +71,7 @@ extension SelectedSiteSettings { ServiceLocator.currencySettings.updateCurrencyOptions(with: $0) } - NotificationCenter.default.post(name: .selectedSiteSettingsRefreshed, object: nil) + NotificationCenter.default.post(name: .selectedSiteSettingsRefreshed, object: self) // Needed to correcly format the widget data. UserDefaults.group?[.defaultStoreCurrencySettings] = try? JSONEncoder().encode(ServiceLocator.currencySettings) From 8981ae2b6e975759c28d3d7343ff09eea8a5c0a6 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 14:54:22 +0800 Subject: [PATCH 05/20] POSEligibilityChecker: wait for initial site settings notification from site initialization when feature flag is enabled. --- .../Settings/POS/POSEligibilityChecker.swift | 52 +++++++++++++++---- .../MainTabBarControllerTests.swift | 32 ++++++------ .../POS/POSEligibilityCheckerTests.swift | 52 ++++++++++++++----- 3 files changed, 96 insertions(+), 40 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index e3720847373..f2b1b294a78 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -26,12 +26,26 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { .eraseToAnyPublisher() } - return Publishers.CombineLatest(isWooCommerceVersionSupportedAndFeatureSwitchEnabled, isPointOfSaleFeatureFlagEnabled) - .filter { [weak self] _ in - self?.isEligibleFromSiteChecks ?? false - } - .map { $0 && $1 } - .eraseToAnyPublisher() + if hasWaitedForSiteSettingsNotification || !featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi1) { + return Publishers.CombineLatest(isWooCommerceVersionSupportedAndFeatureSwitchEnabled, isPointOfSaleFeatureFlagEnabled) + .filter { [weak self] _ in + self?.isEligibleFromSiteSettings() ?? false + } + .map { $0 && $1 } + .eraseToAnyPublisher() + } else { + return Publishers + .CombineLatest3( + isWooCommerceVersionSupportedAndFeatureSwitchEnabled, + isPointOfSaleFeatureFlagEnabled, + initialSiteSettingsEligibilityPublisher + .handleEvents(receiveOutput: { [weak self] _ in + self?.hasWaitedForSiteSettingsNotification = true + }) + ) + .map { $0 && $1 && $2 } + .eraseToAnyPublisher() + } } private let siteID: Int64 @@ -41,6 +55,8 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { private let stores: StoresManager private let featureFlagService: FeatureFlagService + private var hasWaitedForSiteSettingsNotification: Bool = false + init(siteID: Int64, userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, @@ -98,7 +114,10 @@ private extension POSEligibilityChecker { } // For versions that support the feature switch, checks if the feature switch is enabled. - return Future { promise in + return Future { [weak self] promise in + guard let self else { + return promise(.success(false)) + } let action = SettingAction.isFeatureEnabled(siteID: siteID, feature: .pointOfSale) { result in switch result { case .success(let isEnabled): @@ -137,12 +156,23 @@ private extension POSEligibilityChecker { } .eraseToAnyPublisher() } +} + +private extension POSEligibilityChecker { + // Site settings are refreshed async during site initialization, `siteSettings` from `ServiceLocator` could be outdated until the notification. + var initialSiteSettingsEligibilityPublisher: AnyPublisher { + NotificationCenter.default.publisher(for: .selectedSiteSettingsRefreshed, object: siteSettings) + .map { [weak self] _ -> Bool in + guard let self else { return false } + return isEligibleFromSiteSettings() + } + .eraseToAnyPublisher() + } - var isEligibleFromSiteChecks: Bool { - // Conditions that can change if site settings are synced during the lifetime. + func isEligibleFromSiteSettings() -> Bool { let countryCode = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode - let currency = currencySettings.currencyCode - switch (countryCode, currency) { + let currencyCode = currencySettings.currencyCode + switch (countryCode, currencyCode) { case (.US, .USD), (.GB, .GBP): return true diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 714fef6b797..25b4f7adf47 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -120,14 +120,14 @@ final class MainTabBarControllerTests: XCTestCase { // Assert XCTAssertEqual(viewControllersBeforeSiteChange.count, viewControllersAfterSiteChange.count) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.myStore.visibleIndex()], - viewControllersAfterSiteChange[WooTab.myStore.visibleIndex()]) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.orders.visibleIndex()], - viewControllersAfterSiteChange[WooTab.orders.visibleIndex()]) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.products.visibleIndex()], - viewControllersAfterSiteChange[WooTab.products.visibleIndex()]) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.hubMenu.visibleIndex()], - viewControllersAfterSiteChange[WooTab.hubMenu.visibleIndex()]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.myStore.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.myStore.visibleIndex(isPOSTabVisible: false)]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.orders.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.orders.visibleIndex(isPOSTabVisible: false)]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.products.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.products.visibleIndex(isPOSTabVisible: false)]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.hubMenu.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.hubMenu.visibleIndex(isPOSTabVisible: false)]) } func test_tab_view_controllers_stay_the_same_after_updating_to_the_same_site() throws { @@ -172,8 +172,8 @@ final class MainTabBarControllerTests: XCTestCase { let selectedTabIndexAfterSiteChange = tabBarController.selectedIndex // Assert - XCTAssertEqual(selectedTabIndexBeforeSiteChange, WooTab.products.visibleIndex()) - XCTAssertEqual(selectedTabIndexAfterSiteChange, WooTab.myStore.visibleIndex()) + XCTAssertEqual(selectedTabIndexBeforeSiteChange, WooTab.products.visibleIndex(isPOSTabVisible: false)) + XCTAssertEqual(selectedTabIndexAfterSiteChange, WooTab.myStore.visibleIndex(isPOSTabVisible: false)) } func test_when_receiving_a_review_notification_from_a_different_site_navigates_to_hubMenu_tab() throws { @@ -232,7 +232,7 @@ final class MainTabBarControllerTests: XCTestCase { } waitUntil { - tabBarController.selectedIndex == WooTab.hubMenu.visibleIndex() + tabBarController.selectedIndex == WooTab.hubMenu.visibleIndex(isPOSTabVisible: false) } } @@ -466,7 +466,7 @@ final class MainTabBarControllerTests: XCTestCase { } // Then - XCTAssertEqual(tabBarController.selectedIndex, WooTab.products.visibleIndex()) + XCTAssertEqual(tabBarController.selectedIndex, WooTab.products.visibleIndex(isPOSTabVisible: false)) XCTAssertEqual(tabBarController.selectedViewController, navigationController) } @@ -491,7 +491,7 @@ final class MainTabBarControllerTests: XCTestCase { } // Then - XCTAssertEqual(tabBarController.selectedIndex, WooTab.orders.visibleIndex()) + XCTAssertEqual(tabBarController.selectedIndex, WooTab.orders.visibleIndex(isPOSTabVisible: false)) let tabContainerController = try XCTUnwrap(tabBarController.selectedViewController as? TabContainerController) let ordersSplitViewWrapper = try XCTUnwrap(tabContainerController.wrappedController as? OrdersSplitViewWrapperController) let splitViewController = try XCTUnwrap(ordersSplitViewWrapper.children.first as? UISplitViewController) @@ -537,7 +537,7 @@ final class MainTabBarControllerTests: XCTestCase { } // Then - XCTAssertEqual(tabBarController.selectedIndex, WooTab.orders.visibleIndex()) + XCTAssertEqual(tabBarController.selectedIndex, WooTab.orders.visibleIndex(isPOSTabVisible: false)) let tabContainerController = try XCTUnwrap(tabBarController.selectedViewController as? TabContainerController) let ordersSplitViewWrapper = try XCTUnwrap(tabContainerController.wrappedController as? OrdersSplitViewWrapperController) let splitViewController = try XCTUnwrap(ordersSplitViewWrapper.children.first as? UISplitViewController) @@ -578,7 +578,7 @@ private extension MainTabBarController { func tabRootViewController(tab: WooTab) -> UIViewController? { // swiftlint:disable:next empty_enum_arguments - guard let viewController = tabRootViewControllers[safe: tab.visibleIndex()] else { + guard let viewController = tabRootViewControllers[safe: tab.visibleIndex(isPOSTabVisible: false)] else { XCTFail("Unexpected access to root controller at tab: \(tab)") return nil } @@ -586,7 +586,7 @@ private extension MainTabBarController { } func tabContainerController(tab: WooTab) -> UIViewController? { - guard let viewController = viewControllers?[tab.visibleIndex()] else { + guard let viewController = viewControllers?[tab.visibleIndex(isPOSTabVisible: false)] else { XCTFail("Unexpected access to container controller at tab: \(tab)") return nil } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift index 098d6ab9441..5b7ceda9a9a 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift @@ -31,9 +31,11 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_all_conditions_satisfied_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -47,9 +49,11 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_account_not_whitelisted_in_backend_and_enabled_via_local_feature_flag_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleBarcodeScanningi1] = false setupCountry(country: .us) accountWhitelistedInBackend(false) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -63,9 +67,11 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_account_not_whitelisted_in_backend_and_not_enabled_via_local_feature_flag_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(false) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -79,10 +85,12 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_non_iPad_device_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] .forEach { userInterfaceIdiom in - let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: userInterfaceIdiom, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -97,11 +105,13 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_non_us_site_then_returns_false() { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false [Country.ca, Country.es, Country.gb].forEach { country in // When setupCountry(country: country) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -116,9 +126,11 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_when_non_usd_currency_then_isEligible_returns_false() { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.nonUSDCurrencySettings, stores: stores, @@ -132,8 +144,10 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_feature_flag_is_disabled_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -147,13 +161,15 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_WooCommerce_version_is_below_9_6_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) // Unsupported WooCommerce version setupWooCommerceVersion("9.5.2") // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -167,6 +183,7 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_WooCommerce_version_is_at_least_9_6_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -174,7 +191,8 @@ final class POSEligibilityCheckerTests: XCTestCase { setupWooCommerceVersion("9.6.0-beta1") // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -188,6 +206,7 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -196,7 +215,8 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.success(true)) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -210,6 +230,7 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_disabled_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -218,7 +239,8 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.success(false)) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -232,6 +254,7 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_check_fails_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -240,7 +263,8 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -254,6 +278,7 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_smaller_than_10_0_0_and_POS_feature_disabled_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -262,7 +287,8 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.success(false)) // When - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(siteID: siteID, + userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, From c8fbc6cd9b1dd56455026d318557a00e72331a0f Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 15:06:36 +0800 Subject: [PATCH 06/20] Fix lint errors. --- WooCommerce/Classes/ViewRelated/MainTabBarController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index a9ca50c0c8e..0b97f7ff9c6 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -650,7 +650,7 @@ private extension MainTabBarController { viewModel.loadHubMenuTabBadge() } } - + func updateTabViewControllers(isPOSTabVisible: Bool) { var controllers = [UIViewController]() let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) @@ -662,7 +662,7 @@ private extension MainTabBarController { viewControllers = controllers self.isPOSTabVisible = isPOSTabVisible } - + func rootTabViewController(tab: WooTab) -> UIViewController { switch tab { case .myStore: From deefc7f94c8ca7f73f692c2be8add6de3bcd84a1 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 15:06:47 +0800 Subject: [PATCH 07/20] Localize POS tab title. --- WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 7b05733f5ee..78622dec81c 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -3,13 +3,13 @@ import UIKit import SwiftUI import Yosemite +/// View controller that provides the tab bar item for the Point of Sale tab. +/// It is never visible on the screen, only used to provide the tab bar item as all POS UI is full-screen. final class POSTabViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - // TODO: localize and move to SwiftUI if feasible - title = "Point of Sale" - tabBarItem.title = title + tabBarItem.title = NSLocalizedString("pos.tab.title", value: "Point of Sale", comment: "Title for the Point of Sale tab.") tabBarItem.image = .creditCardImage tabBarItem.accessibilityIdentifier = "tab-bar-pos-item" } From 0aae69776948aae91afebf7ef26336099447532a Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 15:12:57 +0800 Subject: [PATCH 08/20] POSTabCoordinator: DI ServiceLocator dependencies. --- .../POS/TabBar/POSTabCoordinator.swift | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 78622dec81c..ecbd73190cf 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -2,6 +2,8 @@ import Foundation import UIKit import SwiftUI import Yosemite +import class WooFoundation.CurrencySettings +import protocol Storage.StorageManagerType /// View controller that provides the tab bar item for the Point of Sale tab. /// It is never visible on the screen, only used to provide the tab bar item as all POS UI is full-screen. @@ -24,6 +26,9 @@ final class POSTabCoordinator { private let storesManager: StoresManager private let posEligibilityChecker: POSEligibilityCheckerProtocol private let credentials: Credentials? + private let storageManager: StorageManagerType + private let currencySettings: CurrencySettings + private let pushNotesManager: PushNotesManager private(set) lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials) @@ -35,33 +40,31 @@ final class POSTabCoordinator { private(set) lazy var posCouponFetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory = { PointOfSaleCouponFetchStrategyFactory(siteID: siteID, - currencySettings: ServiceLocator.currencySettings, + currencySettings: currencySettings, credentials: credentials, - // TODO: DI - storage: ServiceLocator.storageManager) + storage: storageManager) }() private(set) lazy var posCouponProvider: PointOfSaleCouponServiceProtocol = { - let storage = ServiceLocator.storageManager - let currencySettings = ServiceLocator.currencySettings - return PointOfSaleCouponService(siteID: siteID, currencySettings: currencySettings, credentials: credentials, - storage: storage) + storage: storageManager) }() private(set) lazy var barcodeScanService: PointOfSaleBarcodeScanService = { PointOfSaleBarcodeScanService(siteID: siteID, credentials: credentials, - // TODO: DI - currencySettings: ServiceLocator.currencySettings) + currencySettings: currencySettings) }() init(siteID: Int64, tabContainerController: TabContainerController, viewControllerToPresent: UIViewController, storesManager: StoresManager = ServiceLocator.stores, + storageManager: StorageManagerType = ServiceLocator.storageManager, + currencySettings: CurrencySettings = ServiceLocator.currencySettings, + pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager, posEligibilityChecker: POSEligibilityCheckerProtocol) { self.siteID = siteID self.storesManager = storesManager @@ -69,6 +72,9 @@ final class POSTabCoordinator { self.tabContainerController = tabContainerController self.viewControllerToPresent = viewControllerToPresent self.credentials = storesManager.sessionManager.defaultCredentials + self.storageManager = storageManager + self.currencySettings = currencySettings + self.pushNotesManager = pushNotesManager tabContainerController.wrappedController = POSTabViewController() } @@ -93,11 +99,11 @@ private extension POSTabCoordinator { let posView = PointOfSaleEntryPointView( itemsController: PointOfSaleItemsController( itemProvider: PointOfSaleItemService( - currencySettings: ServiceLocator.currencySettings), + currencySettings: currencySettings), itemFetchStrategyFactory: posItemFetchStrategyFactory), purchasableItemsSearchController: PointOfSaleItemsController( itemProvider: PointOfSaleItemService( - currencySettings: ServiceLocator.currencySettings), + currencySettings: currencySettings), itemFetchStrategyFactory: posItemFetchStrategyFactory, initialState: .init(containerState: .content, itemsStack: .init(root: .loaded([], hasMoreItems: true), itemStates: [:]))), @@ -114,7 +120,7 @@ private extension POSTabCoordinator { collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker, searchHistoryService: POSSearchHistoryService(siteID: siteID), popularPurchasableItemsController: PointOfSaleItemsController( - itemProvider: PointOfSaleItemService(currencySettings: ServiceLocator.currencySettings), + itemProvider: PointOfSaleItemService(currencySettings: currencySettings), itemFetchStrategyFactory: posPopularItemFetchStrategyFactory ), barcodeScanService: barcodeScanService @@ -136,9 +142,9 @@ private extension POSTabCoordinator { /// Disables foreground in-app notifications when Point of Sale is active. func updateInAppNotifications(_ isPointOfSaleActive: Bool) { if isPointOfSaleActive { - ServiceLocator.pushNotesManager.disableInAppNotifications() + pushNotesManager.disableInAppNotifications() } else { - ServiceLocator.pushNotesManager.enableInAppNotifications() + pushNotesManager.enableInAppNotifications() } } From 4dc466b52ef61874f15bcd6cc36a8e22021f4e13 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 15:19:34 +0800 Subject: [PATCH 09/20] Disable POS eligibility check when feature flag is disabled. --- WooCommerce/Classes/ViewRelated/MainTabBarController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 0b97f7ff9c6..93eb09a21a6 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -639,6 +639,12 @@ extension MainTabBarController: DeepLinkNavigator { // private extension MainTabBarController { func observePOSEligibilityForPOSTabVisibility(siteID: Int64) { + guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi1) else { + updateTabViewControllers(isPOSTabVisible: false) + viewModel.loadHubMenuTabBadge() + return + } + // Hides POS tab initially. updateTabViewControllers(isPOSTabVisible: false) // Starts observing the POS eligibility state. From a58b3e550b1c1ca04282bf15931a11b79ca74343 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Tue, 10 Jun 2025 15:53:07 +0800 Subject: [PATCH 10/20] Add test cases for `MainTabBarController`. --- .../ViewRelated/MainTabBarController.swift | 14 +- .../MainTabBarControllerTests.swift | 170 +++++++++++++++--- 2 files changed, 158 insertions(+), 26 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 93eb09a21a6..dd80e0a50d9 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -122,10 +122,11 @@ final class MainTabBarController: UITabBarController { private let productImageUploader: ProductImageUploaderProtocol private let stores: StoresManager = ServiceLocator.stores private let analytics: Analytics + private let posEligibilityCheckerFactory: ((_ siteID: Int64) -> POSEligibilityCheckerProtocol) private var productImageUploadErrorsSubscription: AnyCancellable? - private var posEligibilityChecker: POSEligibilityChecker? + private var posEligibilityChecker: POSEligibilityCheckerProtocol? private var posEligibilitySubscription: AnyCancellable? private var isPOSTabVisible: Bool = false @@ -136,11 +137,15 @@ final class MainTabBarController: UITabBarController { featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, noticePresenter: NoticePresenter = ServiceLocator.noticePresenter, productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, - analytics: Analytics = ServiceLocator.analytics) { + analytics: Analytics = ServiceLocator.analytics, + posEligibilityCheckerFactory: ((Int64) -> POSEligibilityCheckerProtocol)? = nil) { self.featureFlagService = featureFlagService self.noticePresenter = noticePresenter self.productImageUploader = productImageUploader self.analytics = analytics + self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in + POSEligibilityChecker(siteID: siteID) + } super.init(coder: coder) } @@ -149,6 +154,9 @@ final class MainTabBarController: UITabBarController { self.noticePresenter = ServiceLocator.noticePresenter self.productImageUploader = ServiceLocator.productImageUploader self.analytics = ServiceLocator.analytics + self.posEligibilityCheckerFactory = { siteID in + POSEligibilityChecker(siteID: siteID) + } super.init(coder: coder) } @@ -716,7 +724,7 @@ private extension MainTabBarController { } // Configures POS tab coordinator once per logged in site session. - let posEligibilityChecker = POSEligibilityChecker(siteID: siteID) + let posEligibilityChecker = posEligibilityCheckerFactory(siteID) self.posEligibilityChecker = posEligibilityChecker posTabCoordinator = POSTabCoordinator( siteID: siteID, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 25b4f7adf47..b3b9a8b513f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -44,6 +44,7 @@ final class MainTabBarControllerTests: XCTestCase { // Arrange // Sets mock `FeatureFlagService` before `MainTabBarController` is initialized so that the feature flags are set correctly. let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, featureFlagService: featureFlagService) }) else { @@ -54,29 +55,34 @@ final class MainTabBarControllerTests: XCTestCase { XCTAssertNotNil(tabBarController.view) // Action - let siteID: Int64 = 134 - stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(storeID: 134) // Assert XCTAssertEqual(tabBarController.viewControllers?.count, 4) - assertThat(tabBarController.tabRootViewController(tab: .myStore), + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), isAnInstanceOf: DashboardViewHostingController.self) - assertThat(tabBarController.tabRootViewController(tab: .orders), + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), isAnInstanceOf: OrdersSplitViewWrapperController.self) - assertThat(tabBarController.tabRootViewController(tab: .products), + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), isAnInstanceOf: ProductsViewController.self) - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu) as? UINavigationController) + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) assertThat(hubMenuNavigationController.topViewController, isAnInstanceOf: HubMenuViewController.self) } - func test_tab_view_controllers_returns_expected_values() throws { - // Arrange + func test_tab_view_controllers_include_pos_tab_when_pos_is_eligible() throws { + // Given let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.isEligibleValue = true guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, featureFlagService: featureFlagService) + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) }) else { return } @@ -84,24 +90,132 @@ final class MainTabBarControllerTests: XCTestCase { // Trigger `viewDidLoad` XCTAssertNotNil(tabBarController.view) - // Action + // When + stores.updateDefaultStore(storeID: 134) + + // Then + waitUntil { + tabBarController.viewControllers?.count == 5 + } + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: true), + isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: true), + isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: true), + isAnInstanceOf: ProductsViewController.self) + assertThat(tabBarController.tabRootViewController(tab: .pointOfSale, isPOSTabVisible: true), + isAnInstanceOf: POSTabViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: true) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_exclude_pos_tab_when_pos_is_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.isEligibleValue = false + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + stores.updateDefaultStore(storeID: 134) + + // Then + XCTAssertEqual(tabBarController.viewControllers?.count, 4) + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), + isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), + isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), + isAnInstanceOf: ProductsViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_exclude_pos_tab_when_feature_flag_is_disabled() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.isEligibleValue = true + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When let siteID: Int64 = 134 stores.updateDefaultStore(storeID: siteID) - // Assert + // Then XCTAssertEqual(tabBarController.viewControllers?.count, 4) - assertThat(tabBarController.tabRootViewController(tab: .myStore), + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), isAnInstanceOf: DashboardViewHostingController.self) - assertThat(tabBarController.tabRootViewController(tab: .orders), + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), isAnInstanceOf: OrdersSplitViewWrapperController.self) - assertThat(tabBarController.tabRootViewController(tab: .products), + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), isAnInstanceOf: ProductsViewController.self) - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu) as? UINavigationController) + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) assertThat(hubMenuNavigationController.topViewController, isAnInstanceOf: HubMenuViewController.self) } + func test_tab_view_controllers_do_not_change_when_pos_eligibility_changes() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.isEligibleValue = false + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + stores.updateDefaultStore(storeID: 134) + + // Then initial state + XCTAssertEqual(tabBarController.viewControllers?.count, 4) + + // When - change POS eligibility + mockPOSEligibilityChecker.isEligibleValue = true + + // Then tabs remain the same + XCTAssertEqual(tabBarController.viewControllers?.count, 4) + } + func test_tab_root_viewControllers_are_replaced_after_updating_to_a_different_site() throws { // Arrange ServiceLocator.setFeatureFlagService(MockFeatureFlagService()) @@ -211,7 +325,7 @@ final class MainTabBarControllerTests: XCTestCase { } } - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu) as? UINavigationController) + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) assertThat(hubMenuNavigationController.topViewController, isAnInstanceOf: HubMenuViewController.self) // Action @@ -351,7 +465,7 @@ final class MainTabBarControllerTests: XCTestCase { let notice = try XCTUnwrap(noticePresenter.queuedNotices.first) notice.actionHandler?() - let productsNavigationController = try XCTUnwrap(tabBarController.tabContainerController(tab: .products)) + let productsNavigationController = try XCTUnwrap(tabBarController.tabContainerController(tab: .products, isPOSTabVisible: false)) waitUntil { productsNavigationController.presentedViewController != nil } @@ -393,7 +507,7 @@ final class MainTabBarControllerTests: XCTestCase { let notice = try XCTUnwrap(noticePresenter.queuedNotices.first) notice.actionHandler?() - let productsNavigationController = try XCTUnwrap(tabBarController.tabContainerController(tab: .products)) + let productsNavigationController = try XCTUnwrap(tabBarController.tabContainerController(tab: .products, isPOSTabVisible: false)) waitUntil { productsNavigationController.presentedViewController != nil } @@ -435,7 +549,7 @@ final class MainTabBarControllerTests: XCTestCase { notice.actionHandler?() let productsNavigationController = try XCTUnwrap(tabBarController - .tabContainerController(tab: .products)) + .tabContainerController(tab: .products, isPOSTabVisible: false)) waitUntil { productsNavigationController.presentedViewController != nil } @@ -576,20 +690,30 @@ private extension MainTabBarController { return rootViewControllers } - func tabRootViewController(tab: WooTab) -> UIViewController? { + func tabRootViewController(tab: WooTab, isPOSTabVisible: Bool) -> UIViewController? { // swiftlint:disable:next empty_enum_arguments - guard let viewController = tabRootViewControllers[safe: tab.visibleIndex(isPOSTabVisible: false)] else { + guard let viewController = tabRootViewControllers[safe: tab.visibleIndex(isPOSTabVisible: isPOSTabVisible)] else { XCTFail("Unexpected access to root controller at tab: \(tab)") return nil } return viewController } - func tabContainerController(tab: WooTab) -> UIViewController? { - guard let viewController = viewControllers?[tab.visibleIndex(isPOSTabVisible: false)] else { + func tabContainerController(tab: WooTab, isPOSTabVisible: Bool) -> UIViewController? { + guard let viewController = viewControllers?[tab.visibleIndex(isPOSTabVisible: isPOSTabVisible)] else { XCTFail("Unexpected access to container controller at tab: \(tab)") return nil } return viewController } } + +// MARK: - MockPOSEligibilityChecker + +private final class MockPOSEligibilityChecker: POSEligibilityCheckerProtocol { + var isEligibleValue: Bool = false + + var isEligible: AnyPublisher { + Just(isEligibleValue).eraseToAnyPublisher() + } +} From 74584a9f7274606cb916ad6551a58a3f2693565e Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 11 Jun 2025 10:27:36 +0800 Subject: [PATCH 11/20] Revert unnecessary changes. --- .../Dashboard/Settings/POS/POSEligibilityChecker.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index f2b1b294a78..95e8981584e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -29,7 +29,7 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { if hasWaitedForSiteSettingsNotification || !featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi1) { return Publishers.CombineLatest(isWooCommerceVersionSupportedAndFeatureSwitchEnabled, isPointOfSaleFeatureFlagEnabled) .filter { [weak self] _ in - self?.isEligibleFromSiteSettings() ?? false + self?.isEligibleFromSiteChecks ?? false } .map { $0 && $1 } .eraseToAnyPublisher() @@ -164,15 +164,15 @@ private extension POSEligibilityChecker { NotificationCenter.default.publisher(for: .selectedSiteSettingsRefreshed, object: siteSettings) .map { [weak self] _ -> Bool in guard let self else { return false } - return isEligibleFromSiteSettings() + return isEligibleFromSiteChecks } .eraseToAnyPublisher() } - func isEligibleFromSiteSettings() -> Bool { + var isEligibleFromSiteChecks: Bool { let countryCode = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode - let currencyCode = currencySettings.currencyCode - switch (countryCode, currencyCode) { + let currency = currencySettings.currencyCode + switch (countryCode, currency) { case (.US, .USD), (.GB, .GBP): return true From f235da998fe3dba52e041e58dcf4ea8b42d407e3 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 11 Jun 2025 15:20:53 +0800 Subject: [PATCH 12/20] Revert changes `POSEligibilityChecker` and use `POSTabEligibilityChecker` for eligibility check instead. --- .../POS/TabBar/POSTabCoordinator.swift | 5 +- .../Settings/POS/POSEligibilityChecker.swift | 57 +++++-------------- .../Hub Menu/HubMenuViewModel.swift | 3 +- .../ViewRelated/MainTabBarController.swift | 23 ++++---- 4 files changed, 28 insertions(+), 60 deletions(-) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index ecbd73190cf..5fcef656329 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -24,7 +24,6 @@ final class POSTabCoordinator { private let tabContainerController: TabContainerController private let viewControllerToPresent: UIViewController private let storesManager: StoresManager - private let posEligibilityChecker: POSEligibilityCheckerProtocol private let credentials: Credentials? private let storageManager: StorageManagerType private let currencySettings: CurrencySettings @@ -64,11 +63,9 @@ final class POSTabCoordinator { storesManager: StoresManager = ServiceLocator.stores, storageManager: StorageManagerType = ServiceLocator.storageManager, currencySettings: CurrencySettings = ServiceLocator.currencySettings, - pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager, - posEligibilityChecker: POSEligibilityCheckerProtocol) { + pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager) { self.siteID = siteID self.storesManager = storesManager - self.posEligibilityChecker = posEligibilityChecker self.tabContainerController = tabContainerController self.viewControllerToPresent = viewControllerToPresent self.credentials = storesManager.sessionManager.defaultCredentials diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index 95e8981584e..77050421bdf 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -26,44 +26,25 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { .eraseToAnyPublisher() } - if hasWaitedForSiteSettingsNotification || !featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi1) { - return Publishers.CombineLatest(isWooCommerceVersionSupportedAndFeatureSwitchEnabled, isPointOfSaleFeatureFlagEnabled) - .filter { [weak self] _ in - self?.isEligibleFromSiteChecks ?? false - } - .map { $0 && $1 } - .eraseToAnyPublisher() - } else { - return Publishers - .CombineLatest3( - isWooCommerceVersionSupportedAndFeatureSwitchEnabled, - isPointOfSaleFeatureFlagEnabled, - initialSiteSettingsEligibilityPublisher - .handleEvents(receiveOutput: { [weak self] _ in - self?.hasWaitedForSiteSettingsNotification = true - }) - ) - .map { $0 && $1 && $2 } - .eraseToAnyPublisher() - } + return Publishers.CombineLatest(isWooCommerceVersionSupportedAndFeatureSwitchEnabled, isPointOfSaleFeatureFlagEnabled) + .filter { [weak self] _ in + self?.isEligibleFromSiteChecks ?? false + } + .map { $0 && $1 } + .eraseToAnyPublisher() } - private let siteID: Int64 private let userInterfaceIdiom: UIUserInterfaceIdiom private let siteSettings: SelectedSiteSettings private let currencySettings: CurrencySettings private let stores: StoresManager private let featureFlagService: FeatureFlagService - private var hasWaitedForSiteSettingsNotification: Bool = false - - init(siteID: Int64, - userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, + init(userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, currencySettings: CurrencySettings = ServiceLocator.currencySettings, stores: StoresManager = ServiceLocator.stores, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { - self.siteID = siteID self.userInterfaceIdiom = userInterfaceIdiom self.siteSettings = siteSettings self.currencySettings = currencySettings @@ -75,7 +56,8 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { private extension POSEligibilityChecker { var isWooCommerceVersionSupported: AnyPublisher<(isSupported: Bool, wcVersion: String?), Never> { Future<(isSupported: Bool, wcVersion: String?), Never> { [weak self] promise in - guard let self else { + guard let self, + let siteID = self.stores.sessionManager.defaultStoreID else { promise(.success((isSupported: false, wcVersion: nil))) return } @@ -101,7 +83,8 @@ private extension POSEligibilityChecker { .flatMap { [weak self] isSupported, wcVersion -> AnyPublisher in guard let self, isSupported, - let wcVersion else { + let wcVersion, + let siteID = self.stores.sessionManager.defaultStoreID else { return Just(false).eraseToAnyPublisher() } @@ -114,10 +97,7 @@ private extension POSEligibilityChecker { } // For versions that support the feature switch, checks if the feature switch is enabled. - return Future { [weak self] promise in - guard let self else { - return promise(.success(false)) - } + return Future { promise in let action = SettingAction.isFeatureEnabled(siteID: siteID, feature: .pointOfSale) { result in switch result { case .success(let isEnabled): @@ -156,20 +136,9 @@ private extension POSEligibilityChecker { } .eraseToAnyPublisher() } -} - -private extension POSEligibilityChecker { - // Site settings are refreshed async during site initialization, `siteSettings` from `ServiceLocator` could be outdated until the notification. - var initialSiteSettingsEligibilityPublisher: AnyPublisher { - NotificationCenter.default.publisher(for: .selectedSiteSettingsRefreshed, object: siteSettings) - .map { [weak self] _ -> Bool in - guard let self else { return false } - return isEligibleFromSiteChecks - } - .eraseToAnyPublisher() - } var isEligibleFromSiteChecks: Bool { + // Conditions that can change if site settings are synced during the lifetime. let countryCode = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode let currency = currencySettings.currencyCode switch (countryCode, currency) { diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 58e751168e2..148601bfd1d 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -178,8 +178,7 @@ final class HubMenuViewModel: ObservableObject { self.blazeEligibilityChecker = blazeEligibilityChecker self.googleAdsEligibilityChecker = googleAdsEligibilityChecker self.cardPresentPaymentsOnboarding = CardPresentPaymentsOnboardingUseCase() - self.posEligibilityChecker = POSEligibilityChecker(siteID: siteID, - siteSettings: ServiceLocator.selectedSiteSettings, + self.posEligibilityChecker = POSEligibilityChecker(siteSettings: ServiceLocator.selectedSiteSettings, currencySettings: ServiceLocator.currencySettings, featureFlagService: featureFlagService) self.analytics = analytics diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index dd80e0a50d9..25985ce9bf4 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -122,11 +122,11 @@ final class MainTabBarController: UITabBarController { private let productImageUploader: ProductImageUploaderProtocol private let stores: StoresManager = ServiceLocator.stores private let analytics: Analytics - private let posEligibilityCheckerFactory: ((_ siteID: Int64) -> POSEligibilityCheckerProtocol) + private let posEligibilityCheckerFactory: ((_ siteID: Int64) -> POSEntryPointEligibilityCheckerProtocol) private var productImageUploadErrorsSubscription: AnyCancellable? - private var posEligibilityChecker: POSEligibilityCheckerProtocol? + private var posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol? private var posEligibilitySubscription: AnyCancellable? private var isPOSTabVisible: Bool = false @@ -138,13 +138,13 @@ final class MainTabBarController: UITabBarController { noticePresenter: NoticePresenter = ServiceLocator.noticePresenter, productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, analytics: Analytics = ServiceLocator.analytics, - posEligibilityCheckerFactory: ((Int64) -> POSEligibilityCheckerProtocol)? = nil) { + posEligibilityCheckerFactory: ((Int64) -> POSEntryPointEligibilityCheckerProtocol)? = nil) { self.featureFlagService = featureFlagService self.noticePresenter = noticePresenter self.productImageUploader = productImageUploader self.analytics = analytics self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in - POSEligibilityChecker(siteID: siteID) + POSTabEligibilityChecker(siteID: siteID) } super.init(coder: coder) } @@ -155,7 +155,7 @@ final class MainTabBarController: UITabBarController { self.productImageUploader = ServiceLocator.productImageUploader self.analytics = ServiceLocator.analytics self.posEligibilityCheckerFactory = { siteID in - POSEligibilityChecker(siteID: siteID) + POSTabEligibilityChecker(siteID: siteID) } super.init(coder: coder) } @@ -655,14 +655,18 @@ private extension MainTabBarController { // Hides POS tab initially. updateTabViewControllers(isPOSTabVisible: false) + // Starts observing the POS eligibility state. - posEligibilitySubscription = posEligibilityChecker?.isEligible - .receive(on: DispatchQueue.main) - .sink { [weak self] isPOSTabVisible in + Task { [weak self] in + guard let self, let posEligibilityChecker else { return } + let eligibility = await posEligibilityChecker.checkEligibility() + let isPOSTabVisible = eligibility == .eligible + await MainActor.run { [weak self] in guard let self else { return } updateTabViewControllers(isPOSTabVisible: isPOSTabVisible) viewModel.loadHubMenuTabBadge() } + } } func updateTabViewControllers(isPOSTabVisible: Bool) { @@ -729,8 +733,7 @@ private extension MainTabBarController { posTabCoordinator = POSTabCoordinator( siteID: siteID, tabContainerController: posContainerController, - viewControllerToPresent: self, - posEligibilityChecker: posEligibilityChecker + viewControllerToPresent: self ) // Configure hub menu tab coordinator once per logged in session potentially with multiple sites. From c12444eeee27de1a199618bca0139a9eeb7c796b Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 11 Jun 2025 15:34:25 +0800 Subject: [PATCH 13/20] Revert changes in `POSEligibilityCheckerTests`. --- .../POS/POSEligibilityCheckerTests.swift | 52 +++++-------------- 1 file changed, 13 insertions(+), 39 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift index 5b7ceda9a9a..098d6ab9441 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift @@ -31,11 +31,9 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_all_conditions_satisfied_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -49,11 +47,9 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_account_not_whitelisted_in_backend_and_enabled_via_local_feature_flag_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleBarcodeScanningi1] = false setupCountry(country: .us) accountWhitelistedInBackend(false) - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -67,11 +63,9 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_account_not_whitelisted_in_backend_and_not_enabled_via_local_feature_flag_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(false) - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -85,12 +79,10 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_non_iPad_device_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] .forEach { userInterfaceIdiom in - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: userInterfaceIdiom, + let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -105,13 +97,11 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_non_us_site_then_returns_false() { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false [Country.ca, Country.es, Country.gb].forEach { country in // When setupCountry(country: country) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -126,11 +116,9 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_when_non_usd_currency_then_isEligible_returns_false() { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.nonUSDCurrencySettings, stores: stores, @@ -144,10 +132,8 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_feature_flag_is_disabled_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -161,15 +147,13 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_WooCommerce_version_is_below_9_6_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) // Unsupported WooCommerce version setupWooCommerceVersion("9.5.2") // When - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -183,7 +167,6 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_WooCommerce_version_is_at_least_9_6_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -191,8 +174,7 @@ final class POSEligibilityCheckerTests: XCTestCase { setupWooCommerceVersion("9.6.0-beta1") // When - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -206,7 +188,6 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_enabled_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -215,8 +196,7 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.success(true)) // When - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -230,7 +210,6 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_disabled_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -239,8 +218,7 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.success(false)) // When - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -254,7 +232,6 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_10_0_0_and_POS_feature_check_fails_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -263,8 +240,7 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.failure(NSError(domain: "test", code: 0))) // When - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -278,7 +254,6 @@ final class POSEligibilityCheckerTests: XCTestCase { func test_is_eligible_when_core_version_is_smaller_than_10_0_0_and_POS_feature_disabled_then_returns_true() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false setupCountry(country: .us) accountWhitelistedInBackend(true) @@ -287,8 +262,7 @@ final class POSEligibilityCheckerTests: XCTestCase { setupPOSFeatureEnabled(.success(false)) // When - let checker = POSEligibilityChecker(siteID: siteID, - userInterfaceIdiom: .pad, + let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, From a2b1ba223100fe7163deeff8fb5601b49ee2cb06 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 11 Jun 2025 15:41:43 +0800 Subject: [PATCH 14/20] MainTabBarControllerTests: fix mock eligibility checker. --- .../MainTabBarControllerTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index b3b9a8b513f..a902b32d650 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -77,7 +77,7 @@ final class MainTabBarControllerTests: XCTestCase { featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.isEligibleValue = true + mockPOSEligibilityChecker.result = .eligible guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, @@ -117,7 +117,7 @@ final class MainTabBarControllerTests: XCTestCase { featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.isEligibleValue = false + mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled) guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, @@ -153,7 +153,7 @@ final class MainTabBarControllerTests: XCTestCase { featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.isEligibleValue = true + mockPOSEligibilityChecker.result = .eligible guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, @@ -190,7 +190,7 @@ final class MainTabBarControllerTests: XCTestCase { featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.isEligibleValue = false + mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled) guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, @@ -210,7 +210,7 @@ final class MainTabBarControllerTests: XCTestCase { XCTAssertEqual(tabBarController.viewControllers?.count, 4) // When - change POS eligibility - mockPOSEligibilityChecker.isEligibleValue = true + mockPOSEligibilityChecker.result = .eligible // Then tabs remain the same XCTAssertEqual(tabBarController.viewControllers?.count, 4) @@ -710,10 +710,10 @@ private extension MainTabBarController { // MARK: - MockPOSEligibilityChecker -private final class MockPOSEligibilityChecker: POSEligibilityCheckerProtocol { - var isEligibleValue: Bool = false +private final class MockPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { + var result: POSEligibilityState = .eligible - var isEligible: AnyPublisher { - Just(isEligibleValue).eraseToAnyPublisher() + func checkEligibility() async -> POSEligibilityState { + result } } From 072f2abbb38a9f8144a94dd9d8c080303a0ba2d8 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 12 Jun 2025 14:46:05 +0800 Subject: [PATCH 15/20] Mark lazy vars as private as they are not used externally. --- WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 5fcef656329..3765f6fcc82 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -29,29 +29,29 @@ final class POSTabCoordinator { private let currencySettings: CurrencySettings private let pushNotesManager: PushNotesManager - private(set) lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { + private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials) }() - private(set) lazy var posPopularItemFetchStrategyFactory: PointOfSaleFixedItemFetchStrategyFactory = { + private lazy var posPopularItemFetchStrategyFactory: PointOfSaleFixedItemFetchStrategyFactory = { PointOfSaleFixedItemFetchStrategyFactory(fixedStrategy: posItemFetchStrategyFactory.popularStrategy()) }() - private(set) lazy var posCouponFetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory = { + private lazy var posCouponFetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory = { PointOfSaleCouponFetchStrategyFactory(siteID: siteID, currencySettings: currencySettings, credentials: credentials, storage: storageManager) }() - private(set) lazy var posCouponProvider: PointOfSaleCouponServiceProtocol = { + private lazy var posCouponProvider: PointOfSaleCouponServiceProtocol = { return PointOfSaleCouponService(siteID: siteID, currencySettings: currencySettings, credentials: credentials, storage: storageManager) }() - private(set) lazy var barcodeScanService: PointOfSaleBarcodeScanService = { + private lazy var barcodeScanService: PointOfSaleBarcodeScanService = { PointOfSaleBarcodeScanService(siteID: siteID, credentials: credentials, currencySettings: currencySettings) From d619b682ff8c8f24e785125ced578890eaabd5d4 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 12 Jun 2025 14:57:44 +0800 Subject: [PATCH 16/20] Only check POS eligibility in the Menu tab when `pointOfSaleAsATabi1` feature is disabled. --- .../Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 148601bfd1d..acd6307a107 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -314,6 +314,10 @@ private extension HubMenuViewModel { } func setupPOSElement() { + guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi1) == false else { + return + } + posEligibilityChecker.isEligible.map { isEligibleForPOS in if isEligibleForPOS { return PointOfSaleEntryPoint() From 70ad95926061b7ec6fd5fc31e2a3b89d05d745b9 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 12 Jun 2025 21:37:49 +0800 Subject: [PATCH 17/20] Remove unused subscription. --- WooCommerce/Classes/ViewRelated/MainTabBarController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 25985ce9bf4..d8e1c074a3f 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -127,7 +127,6 @@ final class MainTabBarController: UITabBarController { private var productImageUploadErrorsSubscription: AnyCancellable? private var posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol? - private var posEligibilitySubscription: AnyCancellable? private var isPOSTabVisible: Bool = false @@ -162,7 +161,6 @@ final class MainTabBarController: UITabBarController { deinit { cancellableSiteID?.cancel() - posEligibilitySubscription?.cancel() } // MARK: - Overridden Methods From 9a2d54eb5c41c950baad295164a8876601d149c8 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 13 Jun 2025 12:56:54 +0800 Subject: [PATCH 18/20] Separate test cases on tabs from `MainTabBarControllerTests` to prevent leftover main thread work from previous test cases from singletons. --- .../Hub Menu/HubMenuCoordinator.swift | 2 +- .../ViewRelated/MainTabBarController.swift | 6 +- .../WooCommerce.xcodeproj/project.pbxproj | 8 + .../Mocks/MockPOSEligibilityChecker.swift | 11 + .../MainTabBarController+TabsTests.swift | 257 +++++++++++++++++ .../MainTabBarControllerTests.swift | 267 ++---------------- 6 files changed, 307 insertions(+), 244 deletions(-) create mode 100644 WooCommerce/WooCommerceTests/Mocks/MockPOSEligibilityChecker.swift create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuCoordinator.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuCoordinator.swift index ab5fda38607..3e8819edc99 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuCoordinator.swift @@ -42,9 +42,9 @@ final class HubMenuCoordinator { } convenience init(tabContainerController: TabContainerController, + storesManager: StoresManager = ServiceLocator.stores, tapToPayBadgePromotionChecker: TapToPayBadgePromotionChecker, willPresentReviewDetailsFromPushNotification: @escaping () async -> Void) { - let storesManager = ServiceLocator.stores self.init(tabContainerController: tabContainerController, storesManager: storesManager, switchStoreUseCase: SwitchStoreUseCase(stores: storesManager), diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index d8e1c074a3f..f500e323cad 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -120,7 +120,7 @@ final class MainTabBarController: UITabBarController { private let featureFlagService: FeatureFlagService private let noticePresenter: NoticePresenter private let productImageUploader: ProductImageUploaderProtocol - private let stores: StoresManager = ServiceLocator.stores + private let stores: StoresManager private let analytics: Analytics private let posEligibilityCheckerFactory: ((_ siteID: Int64) -> POSEntryPointEligibilityCheckerProtocol) @@ -137,11 +137,13 @@ final class MainTabBarController: UITabBarController { noticePresenter: NoticePresenter = ServiceLocator.noticePresenter, productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, analytics: Analytics = ServiceLocator.analytics, + stores: StoresManager = ServiceLocator.stores, posEligibilityCheckerFactory: ((Int64) -> POSEntryPointEligibilityCheckerProtocol)? = nil) { self.featureFlagService = featureFlagService self.noticePresenter = noticePresenter self.productImageUploader = productImageUploader self.analytics = analytics + self.stores = stores self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in POSTabEligibilityChecker(siteID: siteID) } @@ -153,6 +155,7 @@ final class MainTabBarController: UITabBarController { self.noticePresenter = ServiceLocator.noticePresenter self.productImageUploader = ServiceLocator.productImageUploader self.analytics = ServiceLocator.analytics + self.stores = ServiceLocator.stores self.posEligibilityCheckerFactory = { siteID in POSTabEligibilityChecker(siteID: siteID) } @@ -757,6 +760,7 @@ private extension MainTabBarController { func createHubMenuTabCoordinator() -> HubMenuCoordinator { HubMenuCoordinator(tabContainerController: hubMenuContainerController, + storesManager: stores, tapToPayBadgePromotionChecker: viewModel.tapToPayBadgePromotionChecker, willPresentReviewDetailsFromPushNotification: { [weak self] in await withCheckedContinuation { [weak self] continuation in diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index a25902f5daa..1d98c1d601a 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -521,6 +521,8 @@ 02B653AC2429F7BF00A9C839 /* MockTaxClassStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */; }; 02B7C4F62BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */; }; 02B8650F24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */; }; + 02B8E4192DFBC218001D01FD /* MainTabBarController+TabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */; }; + 02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */; }; 02B9243F2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */; }; 02BA12852461674B008D8325 /* Optional+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BA12842461674B008D8325 /* Optional+String.swift */; }; 02BA128B24616B48008D8325 /* ProductFormActionsFactory+VisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BA128A24616B48008D8325 /* ProductFormActionsFactory+VisibilityTests.swift */; }; @@ -3776,6 +3778,8 @@ 02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTaxClassStoresManager.swift; sourceTree = ""; }; 02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleCustomerCardHeaderView.swift; sourceTree = ""; }; 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+SwiftUIPreviewHelpers.swift"; sourceTree = ""; }; + 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainTabBarController+TabsTests.swift"; sourceTree = ""; }; + 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSEligibilityChecker.swift; sourceTree = ""; }; 02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift; sourceTree = ""; }; 02BA12842461674B008D8325 /* Optional+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+String.swift"; sourceTree = ""; }; 02BA128A24616B48008D8325 /* ProductFormActionsFactory+VisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductFormActionsFactory+VisibilityTests.swift"; sourceTree = ""; }; @@ -10072,6 +10076,7 @@ 746791642108D853007CF1DC /* Mocks */ = { isa = PBXGroup; children = ( + 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */, 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */, 0182C8BD2CE3B10E00474355 /* MockReceiptEligibilityUseCase.swift */, EEA3C2202CA5440A000E82EC /* MockFavoriteProductsUseCase.swift */, @@ -12990,6 +12995,7 @@ 45C8B2682316B2440002FA77 /* BillingAddressTableViewCellTests.swift */, 02FE89C6231FAA4100E85EF8 /* MainTabBarControllerTests.swift */, 953728F72B23635300FDF1D1 /* UIAlertController+helpers.swift */, + 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */, ); path = ViewRelated; sourceTree = ""; @@ -17674,6 +17680,7 @@ 0246405F258B122100C10A7D /* PrintShippingLabelCoordinatorTests.swift in Sources */, 314DC4C3268D2F1000444C9E /* MockAppSettingsStoresManager.swift in Sources */, 9379E1A6225537D0006A6BE4 /* TestingAppDelegate.swift in Sources */, + 02B8E4192DFBC218001D01FD /* MainTabBarController+TabsTests.swift in Sources */, 453904F323BB88B5007C4956 /* ProductTaxStatusListSelectorCommandTests.swift in Sources */, DA25ADDF2C87403900AE81FE /* PushNotificationTests.swift in Sources */, 02BAB02124D0235F00F8B06E /* ProductPriceSettingsViewModel+ProductVariationTests.swift in Sources */, @@ -17875,6 +17882,7 @@ D8C11A6222E24C4A00D4A88D /* LedgerTableViewCellTests.swift in Sources */, 027111422913B9FC00F5269A /* AccountCreationFormViewModelTests.swift in Sources */, 2084B7A82C776E1000EFBD2E /* PointOfSaleCardPresentPaymentFoundMultipleReadersAlertViewModelTests.swift in Sources */, + 02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */, DE50295328BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift in Sources */, D802549126552FE1001B2CC1 /* CardPresentModalScanningForReaderTests.swift in Sources */, B98FF4402AAA096200326D16 /* AddressWooTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockPOSEligibilityChecker.swift b/WooCommerce/WooCommerceTests/Mocks/MockPOSEligibilityChecker.swift new file mode 100644 index 00000000000..ed50554e258 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockPOSEligibilityChecker.swift @@ -0,0 +1,11 @@ +import Foundation +@testable import WooCommerce + +final class MockPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { + var result: POSEligibilityState = .eligible + + @MainActor + func checkEligibility() async -> POSEligibilityState { + result + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift new file mode 100644 index 00000000000..5732b6cdfb9 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift @@ -0,0 +1,257 @@ +import TestKit +import XCTest +@testable import WooCommerce + +final class MainTabBarController_TabsTests: XCTestCase { + override func setUp() { + super.setUp() + SessionManager.removeTestingDatabase() + } + + func test_tab_view_controllers_are_not_empty_after_updating_default_site() throws { + // Arrange + // Sets mock `FeatureFlagService` before `MainTabBarController` is initialized so that the feature flags are set correctly. + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, featureFlagService: featureFlagService, stores: storesManager) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // Action + storesManager.updateDefaultStore(storeID: 980) + + // Assert + XCTAssertEqual(tabBarController.viewControllers?.count, 4) + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), + isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), + isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), + isAnInstanceOf: ProductsViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_include_pos_tab_when_pos_is_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.result = .eligible + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + stores: storesManager, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + storesManager.updateDefaultStore(storeID: 314) + + // Then + waitUntil { + tabBarController.tabRootViewControllers.count == 5 + } + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: true), + isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: true), + isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: true), + isAnInstanceOf: ProductsViewController.self) + assertThat(tabBarController.tabRootViewController(tab: .pointOfSale, isPOSTabVisible: true), + isAnInstanceOf: POSTabViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: true) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_exclude_pos_tab_when_pos_is_not_eligible() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.result = .ineligible(reason: .notTablet) + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + stores: storesManager, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + storesManager.updateDefaultStore(storeID: 707) + + // Then + waitUntil { + tabBarController.tabRootViewControllers.count == 4 + } + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), + isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), + isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), + isAnInstanceOf: ProductsViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_exclude_pos_tab_when_feature_flag_is_disabled() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.result = .eligible + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + stores: storesManager, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + storesManager.updateDefaultStore(storeID: 802) + + // Then + XCTAssertEqual(tabBarController.viewControllers?.count, 4) + assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), + isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), + isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), + isAnInstanceOf: ProductsViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_do_not_change_when_pos_eligibility_changes() throws { + // Given + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled) + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + stores: storesManager, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + storesManager.updateDefaultStore(storeID: 303) + + // Then initial state + waitUntil { + tabBarController.tabRootViewControllers.count == 4 + } + + // When - change POS eligibility + mockPOSEligibilityChecker.result = .eligible + + // Then tabs remain the same + XCTAssertEqual(tabBarController.tabRootViewControllers.count, 4) + } + + func test_tab_root_viewControllers_are_replaced_after_updating_to_a_different_site() throws { + // Arrange + let stores = MockStoresManager(sessionManager: .makeForTesting()) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, featureFlagService: MockFeatureFlagService(), stores: stores) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // Action + stores.updateDefaultStore(storeID: 134) + let viewControllersBeforeSiteChange = tabBarController.tabRootViewControllers + stores.updateDefaultStore(storeID: 630) + let viewControllersAfterSiteChange = tabBarController.tabRootViewControllers + + // Assert + XCTAssertEqual(viewControllersBeforeSiteChange.count, viewControllersAfterSiteChange.count) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.myStore.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.myStore.visibleIndex(isPOSTabVisible: false)]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.orders.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.orders.visibleIndex(isPOSTabVisible: false)]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.products.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.products.visibleIndex(isPOSTabVisible: false)]) + XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.hubMenu.visibleIndex(isPOSTabVisible: false)], + viewControllersAfterSiteChange[WooTab.hubMenu.visibleIndex(isPOSTabVisible: false)]) + } + + func test_tab_view_controllers_stay_the_same_after_updating_to_the_same_site() throws { + // Arrange + let stores = MockStoresManager(sessionManager: .makeForTesting()) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, stores: stores) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // Action + let siteID: Int64 = 610 + stores.updateDefaultStore(storeID: siteID) + let viewControllersBeforeSiteChange = try XCTUnwrap(tabBarController.viewControllers) + stores.updateDefaultStore(storeID: siteID) + let viewControllersAfterSiteChange = try XCTUnwrap(tabBarController.viewControllers) + + // Assert + XCTAssertEqual(viewControllersBeforeSiteChange, viewControllersAfterSiteChange) + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index a902b32d650..2fc2297e166 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -39,231 +39,6 @@ final class MainTabBarControllerTests: XCTestCase { super.tearDown() } - func test_tab_view_controllers_are_not_empty_after_updating_default_site() throws { - - // Arrange - // Sets mock `FeatureFlagService` before `MainTabBarController` is initialized so that the feature flags are set correctly. - let featureFlagService = MockFeatureFlagService() - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, featureFlagService: featureFlagService) - }) else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // Action - stores.updateDefaultStore(storeID: 134) - - // Assert - XCTAssertEqual(tabBarController.viewControllers?.count, 4) - assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), - isAnInstanceOf: DashboardViewHostingController.self) - assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), - isAnInstanceOf: OrdersSplitViewWrapperController.self) - assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), - isAnInstanceOf: ProductsViewController.self) - - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) - assertThat(hubMenuNavigationController.topViewController, - isAnInstanceOf: HubMenuViewController.self) - } - - func test_tab_view_controllers_include_pos_tab_when_pos_is_eligible() throws { - // Given - let featureFlagService = MockFeatureFlagService() - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true - - let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.result = .eligible - - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, - featureFlagService: featureFlagService, - posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) - }) else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // When - stores.updateDefaultStore(storeID: 134) - - // Then - waitUntil { - tabBarController.viewControllers?.count == 5 - } - assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: true), - isAnInstanceOf: DashboardViewHostingController.self) - assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: true), - isAnInstanceOf: OrdersSplitViewWrapperController.self) - assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: true), - isAnInstanceOf: ProductsViewController.self) - assertThat(tabBarController.tabRootViewController(tab: .pointOfSale, isPOSTabVisible: true), - isAnInstanceOf: POSTabViewController.self) - - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: true) as? UINavigationController) - assertThat(hubMenuNavigationController.topViewController, - isAnInstanceOf: HubMenuViewController.self) - } - - func test_tab_view_controllers_exclude_pos_tab_when_pos_is_not_eligible() throws { - // Given - let featureFlagService = MockFeatureFlagService() - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true - - let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled) - - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, - featureFlagService: featureFlagService, - posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) - }) else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // When - stores.updateDefaultStore(storeID: 134) - - // Then - XCTAssertEqual(tabBarController.viewControllers?.count, 4) - assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), - isAnInstanceOf: DashboardViewHostingController.self) - assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), - isAnInstanceOf: OrdersSplitViewWrapperController.self) - assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), - isAnInstanceOf: ProductsViewController.self) - - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) - assertThat(hubMenuNavigationController.topViewController, - isAnInstanceOf: HubMenuViewController.self) - } - - func test_tab_view_controllers_exclude_pos_tab_when_feature_flag_is_disabled() throws { - // Given - let featureFlagService = MockFeatureFlagService() - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false - - let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.result = .eligible - - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, - featureFlagService: featureFlagService, - posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) - }) else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // When - let siteID: Int64 = 134 - stores.updateDefaultStore(storeID: siteID) - - // Then - XCTAssertEqual(tabBarController.viewControllers?.count, 4) - assertThat(tabBarController.tabRootViewController(tab: .myStore, isPOSTabVisible: false), - isAnInstanceOf: DashboardViewHostingController.self) - assertThat(tabBarController.tabRootViewController(tab: .orders, isPOSTabVisible: false), - isAnInstanceOf: OrdersSplitViewWrapperController.self) - assertThat(tabBarController.tabRootViewController(tab: .products, isPOSTabVisible: false), - isAnInstanceOf: ProductsViewController.self) - - let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController(tab: .hubMenu, isPOSTabVisible: false) as? UINavigationController) - assertThat(hubMenuNavigationController.topViewController, - isAnInstanceOf: HubMenuViewController.self) - } - - func test_tab_view_controllers_do_not_change_when_pos_eligibility_changes() throws { - // Given - let featureFlagService = MockFeatureFlagService() - featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true - - let mockPOSEligibilityChecker = MockPOSEligibilityChecker() - mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled) - - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, - featureFlagService: featureFlagService, - posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) - }) else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // When - stores.updateDefaultStore(storeID: 134) - - // Then initial state - XCTAssertEqual(tabBarController.viewControllers?.count, 4) - - // When - change POS eligibility - mockPOSEligibilityChecker.result = .eligible - - // Then tabs remain the same - XCTAssertEqual(tabBarController.viewControllers?.count, 4) - } - - func test_tab_root_viewControllers_are_replaced_after_updating_to_a_different_site() throws { - // Arrange - ServiceLocator.setFeatureFlagService(MockFeatureFlagService()) - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MainTabBarController else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // Action - stores.updateDefaultStore(storeID: 134) - let viewControllersBeforeSiteChange = tabBarController.tabRootViewControllers - stores.updateDefaultStore(storeID: 630) - let viewControllersAfterSiteChange = tabBarController.tabRootViewControllers - - // Assert - XCTAssertEqual(viewControllersBeforeSiteChange.count, viewControllersAfterSiteChange.count) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.myStore.visibleIndex(isPOSTabVisible: false)], - viewControllersAfterSiteChange[WooTab.myStore.visibleIndex(isPOSTabVisible: false)]) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.orders.visibleIndex(isPOSTabVisible: false)], - viewControllersAfterSiteChange[WooTab.orders.visibleIndex(isPOSTabVisible: false)]) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.products.visibleIndex(isPOSTabVisible: false)], - viewControllersAfterSiteChange[WooTab.products.visibleIndex(isPOSTabVisible: false)]) - XCTAssertNotEqual(viewControllersBeforeSiteChange[WooTab.hubMenu.visibleIndex(isPOSTabVisible: false)], - viewControllersAfterSiteChange[WooTab.hubMenu.visibleIndex(isPOSTabVisible: false)]) - } - - func test_tab_view_controllers_stay_the_same_after_updating_to_the_same_site() throws { - // Arrange - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MainTabBarController else { - return - } - - // Trigger `viewDidLoad` - XCTAssertNotNil(tabBarController.view) - - // Action - let siteID: Int64 = 134 - stores.updateDefaultStore(storeID: siteID) - let viewControllersBeforeSiteChange = try XCTUnwrap(tabBarController.viewControllers) - stores.updateDefaultStore(storeID: siteID) - let viewControllersAfterSiteChange = try XCTUnwrap(tabBarController.viewControllers) - - // Assert - XCTAssertEqual(viewControllersBeforeSiteChange, viewControllersAfterSiteChange) - } - func test_selected_tab_is_dashboard_after_navigating_to_products_tab_then_updating_to_a_different_site() throws { // Arrange ServiceLocator.setFeatureFlagService(MockFeatureFlagService()) @@ -292,21 +67,33 @@ final class MainTabBarControllerTests: XCTestCase { func test_when_receiving_a_review_notification_from_a_different_site_navigates_to_hubMenu_tab() throws { // Arrange - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MainTabBarController else { - return - } - let pushNotificationsManager = MockPushNotificationsManager() ServiceLocator.setPushNotesManager(pushNotificationsManager) + let featureFlagService = MockFeatureFlagService() + featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true + + // Hides POS tab. + let mockPOSEligibilityChecker = MockPOSEligibilityChecker() + mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled) + let storesManager = MockStoresManager(sessionManager: .testingInstance) // Reset `receivedActions` storesManager.reset() ServiceLocator.setStores(storesManager) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: featureFlagService, + stores: storesManager, + posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker }) + }) else { + return + } + // Trigger `viewDidLoad` XCTAssertNotNil(tabBarController.view) - stores.updateDefaultStore(storeID: 134) + storesManager.updateDefaultStore(storeID: 782) // Simulate successful state resetting after logging out from push notification store switching storesManager.whenReceivingAction(ofType: StatsActionV4.self) { action in @@ -587,12 +374,18 @@ final class MainTabBarControllerTests: XCTestCase { func test_navigateToOrderDetails_for_the_same_store_switches_to_orders_tab_and_opens_order() throws { // Given let siteID: Int64 = 256 + let stores = MockStoresManager(sessionManager: .makeForTesting()) stores.updateDefaultStore(storeID: siteID) + ServiceLocator.setStores(stores) let mockFeatureFlagService = MockFeatureFlagService() ServiceLocator.setFeatureFlagService(mockFeatureFlagService) - let tabBarController = try XCTUnwrap(UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MainTabBarController) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, featureFlagService: mockFeatureFlagService, stores: stores) + }) else { + return + } TestingAppDelegate.mockTabBarController = tabBarController // Trigger `viewDidLoad` @@ -665,7 +458,7 @@ final class MainTabBarControllerTests: XCTestCase { } } -private extension MainTabBarController { +extension MainTabBarController { var tabRootViewControllers: [UIViewController] { var rootViewControllers = [UIViewController]() @@ -707,13 +500,3 @@ private extension MainTabBarController { return viewController } } - -// MARK: - MockPOSEligibilityChecker - -private final class MockPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { - var result: POSEligibilityState = .eligible - - func checkEligibility() async -> POSEligibilityState { - result - } -} From 386fc90447a40b9a931cd69af74ab18eb7f0e399 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 13 Jun 2025 12:57:54 +0800 Subject: [PATCH 19/20] Make POS eligibility check task cancellable in deinit. Simplify code in Task. --- .../Classes/ViewRelated/MainTabBarController.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index f500e323cad..cac23a15200 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -127,6 +127,7 @@ final class MainTabBarController: UITabBarController { private var productImageUploadErrorsSubscription: AnyCancellable? private var posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol? + private var posEligibilityCheckTask: Task? private var isPOSTabVisible: Bool = false @@ -164,6 +165,7 @@ final class MainTabBarController: UITabBarController { deinit { cancellableSiteID?.cancel() + posEligibilityCheckTask?.cancel() } // MARK: - Overridden Methods @@ -657,16 +659,16 @@ private extension MainTabBarController { // Hides POS tab initially. updateTabViewControllers(isPOSTabVisible: false) + // Cancels any existing task. + posEligibilityCheckTask?.cancel() + // Starts observing the POS eligibility state. - Task { [weak self] in + posEligibilityCheckTask = Task { @MainActor [weak self] in guard let self, let posEligibilityChecker else { return } let eligibility = await posEligibilityChecker.checkEligibility() let isPOSTabVisible = eligibility == .eligible - await MainActor.run { [weak self] in - guard let self else { return } - updateTabViewControllers(isPOSTabVisible: isPOSTabVisible) - viewModel.loadHubMenuTabBadge() - } + updateTabViewControllers(isPOSTabVisible: isPOSTabVisible) + viewModel.loadHubMenuTabBadge() } } From 214e1e3cdd1e687e26816133d6407474bf6d2f62 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 13 Jun 2025 13:08:43 +0800 Subject: [PATCH 20/20] Revert unnecessary changes in `MainTabBarControllerTests.test_navigateToOrderDetails_for_the_same_store_switches_to_orders_tab_and_opens_order`. --- .../ViewRelated/MainTabBarControllerTests.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 2fc2297e166..2c4c18322cd 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -374,18 +374,12 @@ final class MainTabBarControllerTests: XCTestCase { func test_navigateToOrderDetails_for_the_same_store_switches_to_orders_tab_and_opens_order() throws { // Given let siteID: Int64 = 256 - let stores = MockStoresManager(sessionManager: .makeForTesting()) stores.updateDefaultStore(storeID: siteID) - ServiceLocator.setStores(stores) let mockFeatureFlagService = MockFeatureFlagService() ServiceLocator.setFeatureFlagService(mockFeatureFlagService) - guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in - return MainTabBarController(coder: coder, featureFlagService: mockFeatureFlagService, stores: stores) - }) else { - return - } + let tabBarController = try XCTUnwrap(UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MainTabBarController) TestingAppDelegate.mockTabBarController = tabBarController // Trigger `viewDidLoad`