Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

23.6
-----
- [*] Handle sites configured with `http` siteAddress.
- [*] 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]

23.5
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 49 additions & 25 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand All @@ -184,6 +186,7 @@ final class MainTabBarController: UITabBarController {
self.bookingsEligibilityCheckerFactory = bookingsEligibilityCheckerFactory ?? { site in
BookingsTabEligibilityChecker(site: site)
}
self.userDefaults = userDefaults
super.init(coder: coder)
}

Expand All @@ -201,6 +204,7 @@ final class MainTabBarController: UITabBarController {
self.bookingsEligibilityCheckerFactory = { site in
BookingsTabEligibilityChecker(site: site)
}
self.userDefaults = .standard
super.init(coder: coder)
}

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = posTabVisibilityCheckerFactory(site)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: why don't we set the checker using the local variable?

Suggested change
self.posTabVisibilityChecker = posTabVisibilityCheckerFactory(site)
self.posTabVisibilityChecker = posTabVisibilityChecker

Copy link
Contributor Author

@RafaelKayumov RafaelKayumov Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in dec1f90


// Sets POS tab initial visibility based on cached value if available.
let initialVisibility = posTabVisibilityChecker.checkInitialVisibility()
Expand Down Expand Up @@ -770,7 +799,8 @@ private extension MainTabBarController {
return
}

observeConditionalTabsAvailabilityWith(site)
observePOSEligibilityForPOSTabVisibility(site: site)
observeBookingsEligibilityForBookingsTabVisibility(site: site)
}
}

Expand All @@ -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)

Expand Down Expand Up @@ -830,7 +850,7 @@ private extension MainTabBarController {
isBookingsTabVisible: isBookingsTabVisible)

// Create POS tab coordinator with initial visibility from cache
let initialPOSTabVisibility = posTabVisibilityChecker?.checkInitialVisibility() ?? false
let initialPOSTabVisibility = posTabVisibilityChecker?.checkInitialVisibility() ?? isPOSTabVisible
posTabCoordinator = POSTabCoordinator(
siteID: siteID,
tabContainerController: posContainerController,
Expand All @@ -840,6 +860,10 @@ private extension MainTabBarController {
initialPOSTabVisibility: initialPOSTabVisibility
)

// 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)
}
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
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,
Expand Down Expand Up @@ -177,6 +181,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
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,
Expand Down Expand Up @@ -206,6 +214,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
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,
Expand Down Expand Up @@ -235,6 +247,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
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,
Expand Down Expand Up @@ -276,6 +292,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
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,
Expand Down Expand Up @@ -318,6 +338,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
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,
Expand Down Expand Up @@ -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()
Expand Down
Loading