diff --git a/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift b/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift index 15d099e7393..dcbc5301bf9 100644 --- a/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift +++ b/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift @@ -1,4 +1,3 @@ -// periphery:ignore:all import Foundation import Yosemite import Experiments diff --git a/WooCommerce/Classes/Bookings/BookingsTabView.swift b/WooCommerce/Classes/Bookings/BookingsTabView.swift new file mode 100644 index 00000000000..32c9e80c28a --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingsTabView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// Hosting view for `BookingsTabView` +/// +final class BookingsTabViewHostingController: UIHostingController { + // periphery: ignore + init(siteID: Int64) { + super.init(rootView: BookingsTabView()) + configureTabBarItem() + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var shouldShowOfflineBanner: Bool { + return true + } + + // periphery: ignore + func didSwitchStore(id: Int64) { + // TODO: update view + } +} + +private extension BookingsTabViewHostingController { + func configureTabBarItem() { + tabBarItem.image = UIImage(systemName: "calendar") + tabBarItem.title = "Bookings" + tabBarItem.accessibilityIdentifier = "tab-bar-bookings-item" + } +} + +/// Main content of the Bookings tab +/// +struct BookingsTabView: View { + @State private var visibility: NavigationSplitViewVisibility = .all + + var body: some View { + NavigationSplitView(columnVisibility: $visibility) { + Text("Booking List") + } detail: { + Text("Booking Detail Screen") + } + .navigationSplitViewStyle(.balanced) + } +} diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 6b16bfa7e57..a4c2eccd1c2 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -23,7 +23,7 @@ final class POSTabViewController: UIViewController { /// Coordinator for the Point of Sale tab. /// final class POSTabCoordinator { - private let siteID: Int64 + private(set) var siteID: Int64 private let tabContainerController: TabContainerController private let viewControllerToPresent: UIViewController private let storesManager: StoresManager @@ -107,12 +107,55 @@ final class POSTabCoordinator { } func onTabSelected() { - presentPOSView() + presentPOSView(siteID: siteID) + } + + func didSwitchStore(id: Int64) { + self.siteID = id + + // Resets lazy properties so they get recreated with new siteID + posItemFetchStrategyFactory = PointOfSaleItemFetchStrategyFactory( + siteID: siteID, + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported + ) + + posPopularItemFetchStrategyFactory = + PointOfSaleFixedItemFetchStrategyFactory( + fixedStrategy: posItemFetchStrategyFactory.popularStrategy() + ) + + posCouponFetchStrategyFactory = PointOfSaleCouponFetchStrategyFactory( + siteID: siteID, + currencySettings: currencySettings, + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, + storage: storageManager + ) + + posCouponProvider = PointOfSaleCouponService( + siteID: siteID, + currencySettings: currencySettings, + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, + storage: storageManager + ) + + barcodeScanService = PointOfSaleBarcodeScanService( + siteID: siteID, + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, + currencySettings: currencySettings + ) } } private extension POSTabCoordinator { - func presentPOSView() { + func presentPOSView(siteID: Int64) { Task { @MainActor [weak self] in guard let self else { return } let serviceAdaptor = POSServiceLocatorAdaptor() diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index a8276b1d346..0e5ba9eec75 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -23,6 +23,10 @@ enum WooTab { /// case products + /// Bookings Tab + /// + case bookings + /// Point of Sale Tab /// case pointOfSale @@ -38,15 +42,18 @@ extension WooTab { /// - Parameters: /// - visibleIndex: the index of visible tabs on the tab bar /// - isPOSTabVisible: indicates if the Point of Sale tab is visible. - init(visibleIndex: Int, isPOSTabVisible: Bool) { - let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) + /// - isBookingsTabVisible: indicates if the Bookings tab is visible. + init(visibleIndex: Int, isPOSTabVisible: Bool, isBookingsTabVisible: Bool = false) { + let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) self = tabs[visibleIndex] } /// Returns the visible tab index. - /// - Parameter isPOSTabVisible: indicates if the Point of Sale tab is visible. - func visibleIndex(isPOSTabVisible: Bool) -> Int { - let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) + /// - Parameters: + /// - isPOSTabVisible: indicates if the Point of Sale tab is visible. + /// - isBookingsTabVisible: indicates if the Bookings tab is visible. + func visibleIndex(isPOSTabVisible: Bool, isBookingsTabVisible: Bool = false) -> Int { + let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) 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 @@ -56,14 +63,23 @@ extension WooTab { /// 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. + /// - Parameters: + /// - isPOSTabVisible: indicates if the Point of Sale tab is visible. + /// - isBookingsTabVisible: indicates if the Bookings tab is visible. /// - Returns: visible tabs in the tab bar. - static func visibleTabs(isPOSTabVisible: Bool) -> [WooTab] { + static func visibleTabs(isPOSTabVisible: Bool, isBookingsTabVisible: Bool = false) -> [WooTab] { + var tabs: [WooTab] = [.myStore, .orders, .products] + + if isBookingsTabVisible { + tabs.append(.bookings) + } + if isPOSTabVisible { - return [.myStore, .orders, .products, .pointOfSale, .hubMenu] - } else { - return [.myStore, .orders, .products, .hubMenu] + tabs.append(.pointOfSale) } + + tabs.append(.hubMenu) + return tabs } } @@ -114,6 +130,8 @@ final class MainTabBarController: UITabBarController { private let posContainerController = TabContainerController() private var posTabCoordinator: POSTabCoordinator? + private let bookingsContainerController = TabContainerController() + private let hubMenuContainerController = TabContainerController() private var hubMenuTabCoordinator: HubMenuCoordinator? @@ -126,6 +144,7 @@ final class MainTabBarController: UITabBarController { private let analytics: Analytics private let posEligibilityCheckerFactory: ((_ site: Site) -> POSEntryPointEligibilityCheckerProtocol) private let posEligibilityService: POSEligibilityServiceProtocol + private let bookingsEligibilityCheckerFactory: ((_ site: Site) -> BookingsTabEligibilityCheckerProtocol) private var productImageUploadErrorsSubscription: AnyCancellable? @@ -133,7 +152,12 @@ final class MainTabBarController: UITabBarController { private var posEligibilityCheckTask: Task? private var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? + /// periphery: ignore - keeping strong ref of the checker to keep its async task alive + private var bookingsEligibilityChecker: BookingsTabEligibilityCheckerProtocol? + private var bookingsEligibilityCheckTask: Task? + private var isPOSTabVisible: Bool = false + private var isBookingsTabVisible: Bool = false private lazy var isProductsSplitViewFeatureFlagOn = featureFlagService.isFeatureFlagEnabled(.splitViewInProductsTab) @@ -145,7 +169,8 @@ final class MainTabBarController: UITabBarController { analytics: Analytics = ServiceLocator.analytics, stores: StoresManager = ServiceLocator.stores, posEligibilityCheckerFactory: ((Site) -> POSEntryPointEligibilityCheckerProtocol)? = nil, - posEligibilityService: POSEligibilityServiceProtocol = POSEligibilityService()) { + posEligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(), + bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil) { self.featureFlagService = featureFlagService self.noticePresenter = noticePresenter self.productImageUploader = productImageUploader @@ -159,6 +184,9 @@ final class MainTabBarController: UITabBarController { } } self.posEligibilityService = posEligibilityService + self.bookingsEligibilityCheckerFactory = bookingsEligibilityCheckerFactory ?? { site in + BookingsTabEligibilityChecker(site: site) + } super.init(coder: coder) } @@ -177,12 +205,16 @@ final class MainTabBarController: UITabBarController { } } self.posEligibilityService = POSEligibilityService() + self.bookingsEligibilityCheckerFactory = { site in + BookingsTabEligibilityChecker(site: site) + } super.init(coder: coder) } deinit { cancellableSiteID?.cancel() posEligibilityCheckTask?.cancel() + bookingsEligibilityCheckTask?.cancel() } // MARK: - Overridden Methods @@ -193,8 +225,8 @@ final class MainTabBarController: UITabBarController { delegate = self - // POS tab is hidden by default. - updateTabViewControllers(isPOSTabVisible: false) + // POS and Bookings tabs are hidden by default. + updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: false) observeSiteIDForViewControllers() observeSiteForConditionalTabs() observeProductImageUploadStatusUpdates() @@ -222,11 +254,11 @@ final class MainTabBarController: UITabBarController { } override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - let currentlySelectedTab = WooTab(visibleIndex: selectedIndex, isPOSTabVisible: isPOSTabVisible) + let currentlySelectedTab = WooTab(visibleIndex: selectedIndex, isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) guard let userSelectedIndex = tabBar.items?.firstIndex(of: item) else { return } - let userSelectedTab = WooTab(visibleIndex: userSelectedIndex, isPOSTabVisible: isPOSTabVisible) + let userSelectedTab = WooTab(visibleIndex: userSelectedIndex, isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) // Did we reselect the already-selected tab? if currentlySelectedTab == userSelectedTab { @@ -256,7 +288,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(isPOSTabVisible: isPOSTabVisible) + selectedIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) guard let selectedViewController else { return } @@ -363,6 +395,9 @@ private extension MainTabBarController { case .products: ServiceLocator.analytics.track( event: .Products.productListSelected(horizontalSizeClass: UITraitCollection.current.horizontalSizeClass)) + case .bookings: + // TODO: Add bookings tab selected analytics + break case .hubMenu: ServiceLocator.analytics.track(.hubMenuTabSelected) case .pointOfSale: @@ -383,6 +418,9 @@ private extension MainTabBarController { case .products: ServiceLocator.analytics.track( event: .Products.productListReselected(horizontalSizeClass: UITraitCollection.current.horizontalSizeClass)) + case .bookings: + // TODO: Add bookings tab reselected analytics + break case .hubMenu: ServiceLocator.analytics.track(.hubMenuTabReselected) break @@ -669,14 +707,14 @@ extension MainTabBarController: DeepLinkNavigator { private extension MainTabBarController { func observePOSEligibilityForPOSTabVisibility(siteID: Int64) { guard let posEligibilityChecker else { - updateTabViewControllers(isPOSTabVisible: false) + updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: isBookingsTabVisible) viewModel.loadHubMenuTabBadge() return } // Sets POS tab initial visibility based on cached value if available. let initialVisibility = posEligibilityChecker.checkInitialVisibility() - updateTabViewControllers(isPOSTabVisible: initialVisibility) + updateTabViewControllers(isPOSTabVisible: initialVisibility, isBookingsTabVisible: isBookingsTabVisible) // Cancels any existing task. posEligibilityCheckTask?.cancel() @@ -687,7 +725,7 @@ private extension MainTabBarController { let isPOSTabVisible = await posEligibilityChecker.checkVisibility() analytics.track(.pointOfSaleTabVisibilityChecked, withProperties: ["is_visible": isPOSTabVisible]) cachePOSTabVisibility(siteID: siteID, isPOSTabVisible: isPOSTabVisible) - updateTabViewControllers(isPOSTabVisible: isPOSTabVisible) + updateTabViewControllers(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) viewModel.loadHubMenuTabBadge() // Trigger POS catalog sync if tab is visible and feature flag is enabled @@ -697,19 +735,20 @@ private extension MainTabBarController { } } - func updateTabViewControllers(isPOSTabVisible: Bool) { - guard isPOSTabVisible != self.isPOSTabVisible || (viewControllers?.count ?? 0) == 0 else { + func updateTabViewControllers(isPOSTabVisible: Bool, isBookingsTabVisible: Bool = false) { + guard isPOSTabVisible != self.isPOSTabVisible || isBookingsTabVisible != self.isBookingsTabVisible || (viewControllers?.count ?? 0) == 0 else { return } var controllers = [UIViewController]() - let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible) + let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) tabs.forEach { tab in - let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) + let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) let tabViewController = rootTabViewController(tab: tab) controllers.insert(tabViewController, at: tabIndex) } viewControllers = controllers self.isPOSTabVisible = isPOSTabVisible + self.isBookingsTabVisible = isBookingsTabVisible } func rootTabViewController(tab: WooTab) -> UIViewController { @@ -720,6 +759,8 @@ private extension MainTabBarController { return ordersContainerController case .products: return isProductsSplitViewFeatureFlagOn ? productsContainerController: productsNavigationController + case .bookings: + return bookingsContainerController case .hubMenu: return hubMenuContainerController case .pointOfSale: @@ -761,6 +802,11 @@ private extension MainTabBarController { ) observePOSEligibilityForPOSTabVisibility(siteID: site.siteID) + + // Configures Booking tab. + let bookingsViewController = createBookingsViewController(siteID: site.siteID) + bookingsContainerController.wrappedController = bookingsViewController + observeBookingsEligibilityForBookingsTabVisibility(site: site) } func updateViewControllers(siteID: Int64?) { @@ -796,8 +842,15 @@ private extension MainTabBarController { } hubMenuTabCoordinator?.activate(siteID: siteID) - // Set dashboard to be the default tab. - selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: isPOSTabVisible) + // Sets dashboard to be the default tab. + selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: isPOSTabVisible, + isBookingsTabVisible: isBookingsTabVisible) + + // Updates site ID for the POS coordinator to ensure correct data + posTabCoordinator?.didSwitchStore(id: siteID) + + // Updates site ID for the bookings tab to display correct bookings + (bookingsContainerController.wrappedController as? BookingsTabViewHostingController)?.didSwitchStore(id: siteID) } func createDashboardViewController(siteID: Int64) -> UIViewController { @@ -831,6 +884,10 @@ private extension MainTabBarController { } } + func createBookingsViewController(siteID: Int64) -> UIViewController { + BookingsTabViewHostingController(siteID: siteID) + } + func createHubMenuTabCoordinator() -> HubMenuCoordinator { HubMenuCoordinator(tabContainerController: hubMenuContainerController, storesManager: stores, @@ -843,6 +900,26 @@ private extension MainTabBarController { } }) } + + func observeBookingsEligibilityForBookingsTabVisibility(site: Site) { + let bookingsEligibilityChecker = bookingsEligibilityCheckerFactory(site) + self.bookingsEligibilityChecker = bookingsEligibilityChecker + + // Sets Bookings tab initial visibility based on cached value if available. + let initialVisibility = bookingsEligibilityChecker.checkInitialVisibility() + updateTabViewControllers(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: initialVisibility) + + // Cancels any existing task. + bookingsEligibilityCheckTask?.cancel() + + // Starts observing the Bookings eligibility state. + bookingsEligibilityCheckTask = Task { @MainActor [weak self] in + guard let self else { return } + let isBookingsTabVisible = await bookingsEligibilityChecker.checkVisibility() + // TODO: Add analytics tracking for bookings tab visibility + updateTabViewControllers(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) + } + } } // MARK: - Hub Menu Tab Badge Updates @@ -863,7 +940,7 @@ private extension MainTabBarController { func updateMenuTabBadge(with action: NotificationBadgeActionType) { let tab = WooTab.hubMenu - let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) + let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) let input = NotificationsBadgeInput(action: action, tab: tab, tabBar: self.tabBar, tabIndex: tabIndex) self.notificationsBadge.updateBadge(with: input) @@ -880,7 +957,7 @@ private extension MainTabBarController { } let tab = WooTab.orders - let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible) + let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) guard let orderTab: UITabBarItem = self.tabBar.items?[tabIndex] else { return diff --git a/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift b/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift index e97a1e94ccc..c9ede62ab7d 100644 --- a/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift +++ b/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift @@ -65,7 +65,7 @@ final class NotificationsBadgeController { /// private func hideDotOn(with input: NotificationsBadgeInput) { let tag = dotTag(for: input.tab) - if let subviews = input.tabBar.orderedTabBarActionableViews[input.tabIndex].subviews.first?.subviews { + if let subviews = input.tabBar.orderedTabBarActionableViews[safe: input.tabIndex]?.subviews.first?.subviews { for subview in subviews where subview.tag == tag { subview.fadeOut() { _ in subview.removeFromSuperview() diff --git a/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift b/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift index 2a192fd477d..a56e1cff091 100644 --- a/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift +++ b/WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift @@ -10,10 +10,12 @@ extension WooTab { return 1 case .products: return 2 - case .pointOfSale: + case .bookings: return 3 - case .hubMenu: + case .pointOfSale: return 4 + case .hubMenu: + return 5 } } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift index 7ab97cd28e6..40f4e4b3708 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift @@ -158,6 +158,154 @@ final class MainTabBarController_TabsTests: XCTestCase { XCTAssertEqual(tabBarController.tabRootViewControllers.count, 4) } + func test_tab_view_controllers_include_bookings_tab_when_bookings_tab_is_visible() throws { + // Given + let mockBookingsEligibilityChecker = MockBookingsEligibilityChecker() + mockBookingsEligibilityChecker.visibility = true + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: MockFeatureFlagService(), + stores: storesManager, + bookingsEligibilityCheckerFactory: { _ in mockBookingsEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + let siteID: Int64 = 314 + storesManager.updateDefaultStore(storeID: siteID) + storesManager.updateDefaultStore(.fake().copy(siteID: siteID)) + + // Then + waitUntil { + tabBarController.tabRootViewControllers.count == 5 + } + assertThat(tabBarController.tabRootViewController( + tab: .myStore, + isPOSTabVisible: false, + isBookingsTabVisible: true + ), isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController( + tab: .orders, + isPOSTabVisible: false, + isBookingsTabVisible: true + ), isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController( + tab: .products, + isPOSTabVisible: false, + isBookingsTabVisible: true + ), isAnInstanceOf: ProductsViewController.self) + assertThat(tabBarController.tabRootViewController( + tab: .bookings, + isPOSTabVisible: false, + isBookingsTabVisible: true + ), isAnInstanceOf: BookingsTabViewHostingController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController( + tab: .hubMenu, + isPOSTabVisible: false, + isBookingsTabVisible: true + ) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_exclude_bookings_tab_when_bookings_tab_is_not_visible() throws { + // Given + let mockBookingsEligibilityChecker = MockBookingsEligibilityChecker() + mockBookingsEligibilityChecker.visibility = false + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: MockFeatureFlagService(), + stores: storesManager, + bookingsEligibilityCheckerFactory: { _ in mockBookingsEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + let siteID: Int64 = 707 + storesManager.updateDefaultStore(storeID: siteID) + storesManager.updateDefaultStore(.fake().copy(siteID: siteID)) + + // Then + waitUntil { + tabBarController.tabRootViewControllers.count == 4 + } + assertThat(tabBarController.tabRootViewController( + tab: .myStore, + isPOSTabVisible: false, + isBookingsTabVisible: false + ), isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController( + tab: .orders, + isPOSTabVisible: false, + isBookingsTabVisible: false + ), isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController( + tab: .products, + isPOSTabVisible: false, + isBookingsTabVisible: false + ), isAnInstanceOf: ProductsViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController( + tab: .hubMenu, + isPOSTabVisible: false, + isBookingsTabVisible: false + ) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } + + func test_tab_view_controllers_do_not_change_when_bookings_visibility_changes() throws { + // Given + let mockBookingsEligibilityChecker = MockBookingsEligibilityChecker() + mockBookingsEligibilityChecker.visibility = false + + let storesManager = MockStoresManager(sessionManager: .makeForTesting()) + + guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in + return MainTabBarController(coder: coder, + featureFlagService: MockFeatureFlagService(), + stores: storesManager, + bookingsEligibilityCheckerFactory: { _ in mockBookingsEligibilityChecker }) + }) else { + return + } + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When + let siteID: Int64 = 303 + storesManager.updateDefaultStore(storeID: siteID) + storesManager.updateDefaultStore(.fake().copy(siteID: siteID)) + + // Then initial state + waitUntil { + tabBarController.tabRootViewControllers.count == 4 + } + + // When - change bookings eligibility + mockBookingsEligibilityChecker.visibility = true + + // 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()) @@ -219,3 +367,17 @@ final class MainTabBarController_TabsTests: XCTestCase { XCTAssertEqual(viewControllersBeforeSiteChange, viewControllersAfterSiteChange) } } + +private final class MockBookingsEligibilityChecker { + var visibility: Bool = false +} + +extension MockBookingsEligibilityChecker: BookingsTabEligibilityCheckerProtocol { + func checkInitialVisibility() -> Bool { + visibility + } + + func checkVisibility() async -> Bool { + visibility + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 65b006763ab..c1453c7e3b6 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -594,6 +594,77 @@ final class MainTabBarControllerTests: XCTestCase { let indexOfEvent = try XCTUnwrap(analyticsProvider.receivedEvents.firstIndex(of: WooAnalyticsStat.pointOfSaleTabVisibilityChecked.rawValue)) assertEqual(true, analyticsProvider.receivedProperties[safe: indexOfEvent]?["is_visible"] as? Bool) } + + func test_bookings_tab_becomes_invisible_after_being_selected_when_initially_visible_then_eligibility_changes() throws { + // Given + let mockBookingsEligibilityChecker = MockAsyncBookingsEligibilityChecker() + mockBookingsEligibilityChecker.initialVisibility = true + + let mockFeatureFlagService = MockFeatureFlagService() + ServiceLocator.setFeatureFlagService(mockFeatureFlagService) + + 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, + bookingsEligibilityCheckerFactory: { _ in mockBookingsEligibilityChecker }) + }) else { + return + } + + window.rootViewController = tabBarController + + // Trigger `viewDidLoad` + XCTAssertNotNil(tabBarController.view) + + // When bookings tab initial visibility is set to true + let siteID: Int64 = 1126 + stores.updateDefaultStore(storeID: siteID) + stores.updateDefaultStore(.fake().copy(siteID: siteID)) + + // Then bookings tab is visible before eligibility check is returned + waitUntil { + tabBarController.tabRootViewControllers.count == 5 + } + assertThat(tabBarController.tabRootViewController( + tab: .bookings, + isPOSTabVisible: false, + isBookingsTabVisible: true + ), isAnInstanceOf: BookingsTabViewHostingController.self) + + // When bookings tab becomes invisible + mockBookingsEligibilityChecker.setVisibilityResult(false) + + // Then bookings tab is hidden + waitUntil { + tabBarController.tabRootViewControllers.count == 4 + } + + assertThat(tabBarController.tabRootViewController( + tab: .myStore, + isPOSTabVisible: false, + isBookingsTabVisible: false + ), isAnInstanceOf: DashboardViewHostingController.self) + assertThat(tabBarController.tabRootViewController( + tab: .orders, + isPOSTabVisible: false, + isBookingsTabVisible: false + ), isAnInstanceOf: OrdersSplitViewWrapperController.self) + assertThat(tabBarController.tabRootViewController( + tab: .products, + isPOSTabVisible: false, + isBookingsTabVisible: false + ), isAnInstanceOf: ProductsViewController.self) + + let hubMenuNavigationController = try XCTUnwrap(tabBarController.tabRootViewController( + tab: .hubMenu, + isPOSTabVisible: false, + isBookingsTabVisible: false + ) as? UINavigationController) + assertThat(hubMenuNavigationController.topViewController, + isAnInstanceOf: HubMenuViewController.self) + } } extension MainTabBarController { @@ -621,17 +692,20 @@ extension MainTabBarController { return rootViewControllers } - func tabRootViewController(tab: WooTab, isPOSTabVisible: Bool) -> UIViewController? { + func tabRootViewController(tab: WooTab, isPOSTabVisible: Bool, isBookingsTabVisible: Bool = false) -> UIViewController? { // swiftlint:disable:next empty_enum_arguments - guard let viewController = tabRootViewControllers[safe: tab.visibleIndex(isPOSTabVisible: isPOSTabVisible)] else { + guard let viewController = tabRootViewControllers[safe: tab.visibleIndex( + isPOSTabVisible: isPOSTabVisible, + isBookingsTabVisible: isBookingsTabVisible + )] else { XCTFail("Unexpected access to root controller at tab: \(tab)") return nil } return viewController } - func tabContainerController(tab: WooTab, isPOSTabVisible: Bool) -> UIViewController? { - guard let viewController = viewControllers?[tab.visibleIndex(isPOSTabVisible: isPOSTabVisible)] else { + func tabContainerController(tab: WooTab, isPOSTabVisible: Bool, isBookingsTabVisible: Bool = false) -> UIViewController? { + guard let viewController = viewControllers?[tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible)] else { XCTFail("Unexpected access to container controller at tab: \(tab)") return nil } @@ -696,3 +770,34 @@ private final class MockAsyncPOSEligibilityChecker: POSEntryPointEligibilityChec .ineligible(reason: ineligibleReason) } } + +private final class MockAsyncBookingsEligibilityChecker: BookingsTabEligibilityCheckerProtocol { + var initialVisibility: Bool = false + private var visibilityResult: Bool? + private var visibilityContinuation: CheckedContinuation? + + func setVisibilityResult(_ result: Bool) { + visibilityResult = result + if let continuation = visibilityContinuation { + visibilityContinuation = nil + continuation.resume(returning: result) + } + } + + func checkInitialVisibility() -> Bool { + initialVisibility + } + + func checkVisibility() async -> Bool { + if let visibilityResult { + return visibilityResult + } + return await withCheckedContinuation { continuation in + visibilityContinuation = continuation + // If we already have a result, return it immediately. + if visibilityContinuation == nil { + continuation.resume(returning: visibilityResult ?? true) + } + } + } +}