Skip to content
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// periphery:ignore:all
import Foundation
import Yosemite
import Experiments
Expand Down
42 changes: 42 additions & 0 deletions WooCommerce/Classes/Bookings/BookingsTabView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import SwiftUI

/// Hosting view for `BookingsTabView`
///
final class BookingsTabViewHostingController: UIHostingController<BookingsTabView> {
// 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
}
}

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)
}
}
127 changes: 100 additions & 27 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ enum WooTab {
///
case products

/// Bookings Tab
///
case bookings

/// Point of Sale Tab
///
case pointOfSale
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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?

Expand All @@ -126,14 +144,20 @@ 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?

private var posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol?
private var posEligibilityCheckTask: Task<Void, Never>?
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<Void, Never>?

private var isPOSTabVisible: Bool = false
private var isBookingsTabVisible: Bool = false

private lazy var isProductsSplitViewFeatureFlagOn = featureFlagService.isFeatureFlagEnabled(.splitViewInProductsTab)

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

Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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:
Expand Down Expand Up @@ -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?) {
Expand Down Expand Up @@ -796,8 +842,11 @@ private extension MainTabBarController {
}
hubMenuTabCoordinator?.activate(siteID: siteID)

// Set dashboard to be the default tab.
selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: isPOSTabVisible)
// Set dashboard to be the default tab - disable optional tabs by default.
selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: false,
isBookingsTabVisible: false)
updateTabViewControllers(isPOSTabVisible: false,
isBookingsTabVisible: false)
}

func createDashboardViewController(siteID: Int64) -> UIViewController {
Expand Down Expand Up @@ -831,6 +880,10 @@ private extension MainTabBarController {
}
}

func createBookingsViewController(siteID: Int64) -> UIViewController {
BookingsTabViewHostingController(siteID: siteID)
}

func createHubMenuTabCoordinator() -> HubMenuCoordinator {
HubMenuCoordinator(tabContainerController: hubMenuContainerController,
storesManager: stores,
Expand All @@ -843,6 +896,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
Expand All @@ -863,7 +936,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)
Expand All @@ -880,7 +953,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
Expand Down
6 changes: 4 additions & 2 deletions WooCommerce/Classes/ViewRelated/TabBar/WooTab+Tag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Loading