diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index a45ab774468..dc3d11a920a 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,8 @@ 23.6 ----- +- [*] Handle sites configured with `http` siteAddress. [https://github.com/woocommerce/woocommerce-ios/pull/16279] +- [*] Use tabs cache state to display conditional tabs initially. [https://github.com/woocommerce/woocommerce-ios/pull/16292] - [*] Handle sites configured with `http` siteAddress. - [*] Fix: Unable to dismiss keyboard when editing Product Title. [https://github.com/woocommerce/woocommerce-ios/pull/16288] diff --git a/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift b/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift index 9950da83b76..19a6abc3279 100644 --- a/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift +++ b/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift @@ -33,6 +33,15 @@ final class BookingsTabEligibilityChecker: BookingsTabEligibilityCheckerProtocol userDefaults.loadCachedBookingsTabVisibility(siteID: site.siteID) } + /// Checks the initial visibility without the `BookingsTabEligibilityChecker` instsance + /// Used for the initial state check when a site instance hasn't been loaded but a `siteID` is available + static func checkInitialVisibility( + for siteID: Int64, + in userDefaults: UserDefaults = .standard + ) -> Bool { + return userDefaults.loadCachedBookingsTabVisibility(siteID: siteID) + } + /// Checks the final visibility of the Bookings tab. func checkVisibility() async -> Bool { // Check feature flag diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabVisibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabVisibilityChecker.swift index 9bff9ee1dec..fb08ebc9519 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabVisibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabVisibilityChecker.swift @@ -45,6 +45,15 @@ final class POSTabVisibilityChecker: POSTabVisibilityCheckerProtocol { eligibilityService.loadCachedPOSTabVisibility(siteID: site.siteID) ?? false } + /// Checks the initial visibility without the `POSTabVisibilityChecker` instsance + /// Used for the initial state check when a site instance hasn't been loaded but a `siteID` is available + static func checkInitialVisibility( + for siteID: Int64, + eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService() + ) -> Bool { + return eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false + } + /// Checks the final visibility of the POS tab. func checkVisibility() async -> Bool { guard siteCIABEligibilityChecker.isFeatureSupported(.pointOfSale, for: site) else { diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index db294a17f84..952882b0509 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -146,6 +146,7 @@ final class MainTabBarController: UITabBarController { private let posTabVisibilityCheckerFactory: ((_ site: Site) -> POSTabVisibilityCheckerProtocol) private let posEligibilityService: POSEligibilityServiceProtocol private let bookingsEligibilityCheckerFactory: ((_ site: Site) -> BookingsTabEligibilityCheckerProtocol) + private let userDefaults: UserDefaults private var productImageUploadErrorsSubscription: AnyCancellable? @@ -171,7 +172,8 @@ final class MainTabBarController: UITabBarController { stores: StoresManager = ServiceLocator.stores, posTabVisibilityCheckerFactory: ((Site) -> POSTabVisibilityCheckerProtocol)? = nil, posEligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(), - bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil) { + bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil, + userDefaults: UserDefaults = .standard) { self.featureFlagService = featureFlagService self.noticePresenter = noticePresenter self.productImageUploader = productImageUploader @@ -184,6 +186,7 @@ final class MainTabBarController: UITabBarController { self.bookingsEligibilityCheckerFactory = bookingsEligibilityCheckerFactory ?? { site in BookingsTabEligibilityChecker(site: site) } + self.userDefaults = userDefaults super.init(coder: coder) } @@ -201,6 +204,7 @@ final class MainTabBarController: UITabBarController { self.bookingsEligibilityCheckerFactory = { site in BookingsTabEligibilityChecker(site: site) } + self.userDefaults = .standard super.init(coder: coder) } @@ -218,8 +222,9 @@ final class MainTabBarController: UITabBarController { delegate = self - // POS and Bookings tabs are hidden by default. - updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: false) + // Setup initial visibility for conditional tabs (POS, Bookings) + setupConditionalTabsInitialVisibility() + observeSiteIDForViewControllers() observeSiteForConditionalTabs() observeProductImageUploadStatusUpdates() @@ -333,6 +338,30 @@ final class MainTabBarController: UITabBarController { } } } + + private func setupConditionalTabsInitialVisibility() { + guard let siteID = stores.sessionManager.defaultStoreID else { + return + } + + setupConditionalTabsInitialVisibility(for: siteID) + } + + private func setupConditionalTabsInitialVisibility(for siteID: Int64) { + let isPOSTabVisible = POSTabVisibilityChecker.checkInitialVisibility( + for: siteID, + eligibilityService: posEligibilityService + ) + let isBookingsTabVisible = BookingsTabEligibilityChecker.checkInitialVisibility( + for: siteID, + in: userDefaults + ) + + updateTabViewControllers( + isPOSTabVisible: isPOSTabVisible, + isBookingsTabVisible: isBookingsTabVisible + ) + } } // MARK: - UITabBarControllerDelegate @@ -698,12 +727,12 @@ extension MainTabBarController: DeepLinkNavigator { // MARK: - Site ID observation for updating tab view controllers // private extension MainTabBarController { - func observePOSEligibilityForPOSTabVisibility(siteID: Int64) { - guard let posTabVisibilityChecker else { - updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: isBookingsTabVisible) - viewModel.loadHubMenuTabBadge() - return - } + func observePOSEligibilityForPOSTabVisibility(site: Site) { + let siteID = site.siteID + + // Configures POS tab coordinator once per logged in site session. + let posTabVisibilityChecker = posTabVisibilityCheckerFactory(site) + self.posTabVisibilityChecker = posTabVisibilityChecker // Sets POS tab initial visibility based on cached value if available. let initialVisibility = posTabVisibilityChecker.checkInitialVisibility() @@ -770,7 +799,8 @@ private extension MainTabBarController { return } - observeConditionalTabsAvailabilityWith(site) + observePOSEligibilityForPOSTabVisibility(site: site) + observeBookingsEligibilityForBookingsTabVisibility(site: site) } } @@ -783,24 +813,14 @@ private extension MainTabBarController { } } - func observeConditionalTabsAvailabilityWith(_ site: Site) { - // Configures POS tab coordinator once per logged in site session. - let posTabVisibilityChecker = posTabVisibilityCheckerFactory(site) - self.posTabVisibilityChecker = posTabVisibilityChecker - - observePOSEligibilityForPOSTabVisibility(siteID: site.siteID) - - // Configures Booking tab. - let bookingsViewController = createBookingsViewController(siteID: site.siteID) - bookingsContainerController.wrappedController = bookingsViewController - observeBookingsEligibilityForBookingsTabVisibility(site: site) - } - func updateViewControllers(siteID: Int64?) { guard let siteID else { return } + // Update conditional tabs initial state for the `siteID` + setupConditionalTabsInitialVisibility(for: siteID) + // Update view model with `siteID` to query correct Orders Status viewModel.configureOrdersStatusesListener(for: siteID) @@ -840,6 +860,10 @@ private extension MainTabBarController { ) posTabCoordinator = coordinator + // Setup bookings wrapped view controller + let bookingsViewController = createBookingsViewController(siteID: siteID) + bookingsContainerController.wrappedController = bookingsViewController + // Updates site ID for the bookings tab to display correct bookings (bookingsContainerController.wrappedController as? BookingsTabViewHostingController)?.didSwitchStore(id: siteID) } @@ -938,7 +962,7 @@ private extension MainTabBarController { let tab = WooTab.orders let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) - guard let orderTab: UITabBarItem = self.tabBar.items?[tabIndex] else { + guard let orderTab: UITabBarItem = self.tabBar.items?[safe: tabIndex] else { return } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 2dba5a5f5fc..9e803ea4009 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -147,6 +147,10 @@ final class MainTabBarControllerTests: XCTestCase { let statusUpdates = PassthroughSubject() let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher()) + let siteID: Int64 = 134 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, noticePresenter: noticePresenter, @@ -177,6 +181,10 @@ final class MainTabBarControllerTests: XCTestCase { let statusUpdates = PassthroughSubject() let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher()) + let siteID: Int64 = 134 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, noticePresenter: noticePresenter, @@ -206,6 +214,10 @@ final class MainTabBarControllerTests: XCTestCase { let statusUpdates = PassthroughSubject() let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher()) + let siteID: Int64 = 134 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, noticePresenter: noticePresenter, @@ -235,6 +247,10 @@ final class MainTabBarControllerTests: XCTestCase { let statusUpdates = PassthroughSubject() let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher()) + let siteID: Int64 = 134 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, noticePresenter: noticePresenter, @@ -276,6 +292,10 @@ final class MainTabBarControllerTests: XCTestCase { let statusUpdates = PassthroughSubject() let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher()) + let siteID: Int64 = 134 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, noticePresenter: noticePresenter, @@ -318,6 +338,10 @@ final class MainTabBarControllerTests: XCTestCase { let statusUpdates = PassthroughSubject() let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher()) + let siteID: Int64 = 134 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in return MainTabBarController(coder: coder, noticePresenter: noticePresenter, @@ -596,6 +620,96 @@ final class MainTabBarControllerTests: XCTestCase { assertEqual(true, analyticsProvider.receivedProperties[safe: indexOfEvent]?["is_visible"] as? Bool) } + func test_initial_tabs_visibility_is_set_from_cache() throws { + // Given + let siteID: Int64 = 1126 + let mockPOSEligibilityService = MockPOSEligibilityService() + mockPOSEligibilityService.cachedTabVisibility[siteID] = true + + let userDefaults = UserDefaults(suiteName: #function)! + userDefaults.removePersistentDomain(forName: #function) + userDefaults.cacheBookingsTabVisibility(siteID: siteID, isVisible: true) + + let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + stores: stores, + posEligibilityService: mockPOSEligibilityService, + userDefaults: userDefaults) + }) else { + XCTFail("Failed to instantiate MainTabBarController") + return + } + + // When + stores.updateDefaultStore(storeID: siteID) + XCTAssertNotNil(tabBarController.view) // This triggers viewDidLoad + + // Then + let expectedTabs: [WooTab] = [.myStore, .orders, .products, .bookings, .pointOfSale, .hubMenu] + let visibleTabs = WooTab.visibleTabs(isPOSTabVisible: true, isBookingsTabVisible: true) + XCTAssertEqual(tabBarController.viewControllers?.count, expectedTabs.count) + XCTAssertEqual(visibleTabs, expectedTabs) + } + + func test_switching_sites_applies_cached_tab_visibility() throws { + // Arrange + let siteA_ID: Int64 = 101 + let siteB_ID: Int64 = 202 + + // Site A: POS visible, Bookings not visible + let mockPOSEligibilityService = MockPOSEligibilityService() + mockPOSEligibilityService.cachedTabVisibility[siteA_ID] = true + mockPOSEligibilityService.cachedTabVisibility[siteB_ID] = false + + let userDefaults = UserDefaults(suiteName: #function)! + userDefaults.removePersistentDomain(forName: #function) + userDefaults.cacheBookingsTabVisibility(siteID: siteA_ID, isVisible: false) + userDefaults.cacheBookingsTabVisibility(siteID: siteB_ID, isVisible: true) + + let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + + let tabBarController = try XCTUnwrap(UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + MainTabBarController(coder: coder, + stores: stores, + posTabVisibilityCheckerFactory: { site in + POSTabVisibilityChecker(site: site, eligibilityService: mockPOSEligibilityService) + }, + posEligibilityService: mockPOSEligibilityService, + bookingsEligibilityCheckerFactory: { site in + BookingsTabEligibilityChecker(site: site, userDefaults: userDefaults) + }, + userDefaults: userDefaults) + })) + + // Trigger viewDidLoad + XCTAssertNotNil(tabBarController.view) + + // Action 1: Switch to Site A + stores.updateDefaultStore(storeID: siteA_ID) + stores.updateDefaultStore(.fake().copy(siteID: siteA_ID)) + + // Assert 1: Site A should have POS, but not Bookings. + XCTAssertEqual(tabBarController.viewControllers?.count, 5, "There should be 5 tabs for Site A") + XCTAssertFalse(tabBarController.tabRootViewControllers.contains(where: { $0 is BookingsTabViewHostingController }), + "Bookings tab should not be visible for Site A") + XCTAssertTrue(tabBarController.tabRootViewControllers.contains(where: { $0 is POSTabViewController }), + "POS tab should be visible for Site A") + + + // Action 2: Switch to Site B + stores.updateDefaultStore(storeID: siteB_ID) + stores.updateDefaultStore(.fake().copy(siteID: siteB_ID)) + + // Assert 2: Site B should have Bookings, but not POS. + XCTAssertEqual(tabBarController.viewControllers?.count, 5, "There should be 5 tabs for Site B") + XCTAssertTrue(tabBarController.tabRootViewControllers.contains(where: { $0 is BookingsTabViewHostingController }), + "Bookings tab should be visible for Site B") + XCTAssertFalse(tabBarController.tabRootViewControllers.contains(where: { $0 is POSTabViewController }), + "POS tab should not be visible for Site B") + } + func test_bookings_tab_becomes_invisible_after_being_selected_when_initially_visible_then_eligibility_changes() throws { // Given let mockBookingsEligibilityChecker = MockAsyncBookingsEligibilityChecker()