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
10 changes: 8 additions & 2 deletions Storage/Storage/Model/Copiable/Models+Copiable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ extension Storage.GeneralStoreSettings {
lastSelectedStockType: NullableCopiableProp<String> = .copy,
lastSelectedOrderStatus: NullableCopiableProp<String> = .copy,
favoriteProductIDs: CopiableProp<[Int64]> = .copy,
searchTermsByKey: CopiableProp<[String: [String]]> = .copy
searchTermsByKey: CopiableProp<[String: [String]]> = .copy,
isPOSTabVisible: NullableCopiableProp<Bool> = .copy,
lastPOSTabVisibilityCheckDate: NullableCopiableProp<Date> = .copy
) -> Storage.GeneralStoreSettings {
let storeID = storeID ?? self.storeID
let isTelemetryAvailable = isTelemetryAvailable ?? self.isTelemetryAvailable
Expand All @@ -135,6 +137,8 @@ extension Storage.GeneralStoreSettings {
let lastSelectedOrderStatus = lastSelectedOrderStatus ?? self.lastSelectedOrderStatus
let favoriteProductIDs = favoriteProductIDs ?? self.favoriteProductIDs
let searchTermsByKey = searchTermsByKey ?? self.searchTermsByKey
let isPOSTabVisible = isPOSTabVisible ?? self.isPOSTabVisible
let lastPOSTabVisibilityCheckDate = lastPOSTabVisibilityCheckDate ?? self.lastPOSTabVisibilityCheckDate

return Storage.GeneralStoreSettings(
storeID: storeID,
Expand All @@ -155,7 +159,9 @@ extension Storage.GeneralStoreSettings {
lastSelectedStockType: lastSelectedStockType,
lastSelectedOrderStatus: lastSelectedOrderStatus,
favoriteProductIDs: favoriteProductIDs,
searchTermsByKey: searchTermsByKey
searchTermsByKey: searchTermsByKey,
isPOSTabVisible: isPOSTabVisible,
lastPOSTabVisibilityCheckDate: lastPOSTabVisibilityCheckDate
)
}
}
21 changes: 19 additions & 2 deletions Storage/Storage/Model/GeneralStoreSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
///
public var searchTermsByKey: [String: [String]]

/// Whether the POS tab is visible for this store.
///
public var isPOSTabVisible: Bool?

/// The date when the POS tab visibility was last checked.
///
public var lastPOSTabVisibilityCheckDate: Date?

public init(storeID: String? = nil,
isTelemetryAvailable: Bool = false,
telemetryLastReportedTime: Date? = nil,
Expand All @@ -100,7 +108,9 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
lastSelectedStockType: String? = nil,
lastSelectedOrderStatus: String? = nil,
favoriteProductIDs: [Int64] = [],
searchTermsByKey: [String: [String]] = [:]) {
searchTermsByKey: [String: [String]] = [:],
isPOSTabVisible: Bool? = nil,
lastPOSTabVisibilityCheckDate: Date? = nil) {
self.storeID = storeID
self.isTelemetryAvailable = isTelemetryAvailable
self.telemetryLastReportedTime = telemetryLastReportedTime
Expand All @@ -120,6 +130,8 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
self.lastSelectedOrderStatus = lastSelectedOrderStatus
self.favoriteProductIDs = favoriteProductIDs
self.searchTermsByKey = searchTermsByKey
self.isPOSTabVisible = isPOSTabVisible
self.lastPOSTabVisibilityCheckDate = lastPOSTabVisibilityCheckDate
}

public func erasingSelectedTaxRateID() -> GeneralStoreSettings {
Expand All @@ -140,7 +152,9 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
lastSelectedStockType: lastSelectedStockType,
lastSelectedOrderStatus: lastSelectedOrderStatus,
favoriteProductIDs: favoriteProductIDs,
searchTermsByKey: searchTermsByKey)
searchTermsByKey: searchTermsByKey,
isPOSTabVisible: isPOSTabVisible,
lastPOSTabVisibilityCheckDate: lastPOSTabVisibilityCheckDate)
}
}

Expand Down Expand Up @@ -174,6 +188,9 @@ extension GeneralStoreSettings {
forKey: .favoriteProductIDs) ?? []
self.searchTermsByKey = try container.decodeIfPresent([String: [String]].self, forKey: .searchTermsByKey) ?? [:]

self.isPOSTabVisible = try container.decodeIfPresent(Bool.self, forKey: .isPOSTabVisible)
self.lastPOSTabVisibilityCheckDate = try container.decodeIfPresent(Date.self, forKey: .lastPOSTabVisibilityCheckDate)

// Decode new properties with `decodeIfPresent` and provide a default value if necessary.
}
}
1 change: 1 addition & 0 deletions WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ private extension POSTabCoordinator {
guard let self else { return }
let collectOrderPaymentAnalyticsTracker = POSCollectOrderPaymentAnalytics()
let cardPresentPaymentService = await CardPresentPaymentService(siteID: siteID,
stores: storesManager,
collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker)
if let receiptService = POSReceiptService(siteID: siteID,
credentials: credentials),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import protocol Experiments.FeatureFlagService
import struct Yosemite.SiteSetting
import protocol Yosemite.StoresManager
import struct Yosemite.SystemPlugin
import enum Yosemite.SystemStatusAction
import enum Yosemite.AppSettingsAction
import enum Yosemite.FeatureFlagAction
import enum Yosemite.SettingAction
import protocol Yosemite.PluginsServiceProtocol
Expand Down Expand Up @@ -35,6 +35,8 @@ enum POSEligibilityState: Equatable {
}

protocol POSEntryPointEligibilityCheckerProtocol {
/// Checks the initial visibility of the POS tab.
func checkInitialVisibility() async -> Bool
/// Determines whether the site is eligible for POS.
func checkEligibility() async -> POSEligibilityState
}
Expand Down Expand Up @@ -64,6 +66,19 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
self.featureFlagService = featureFlagService
}

/// Checks the initial visibility of the POS tab without dependance on network requests.
@MainActor
func checkInitialVisibility() async -> Bool {
await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume(returning: false)
}
stores.dispatch(AppSettingsAction.loadPOSTabVisibility(siteID: siteID, currentDate: Date()) { isVisible in
continuation.resume(returning: isVisible ?? false)
})
}
}

/// Determines whether the POS entry point can be shown based on the selected store and feature gates.
func checkEligibility() async -> POSEligibilityState {
guard #available(iOS 17.0, *) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ final class HubMenuCoordinator {
///
func activate(siteID: Int64) {
hubMenuController = HubMenuViewController(siteID: siteID,
stores: storesManager,
tapToPayBadgePromotionChecker: tapToPayBadgePromotionChecker)
if let hubMenuController = hubMenuController {
let navigationController = UINavigationController(rootViewController: hubMenuController)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ final class HubMenuViewController: UIHostingController<HubMenu> {
private var shouldShowNavigationBar = false

init(siteID: Int64,
stores: StoresManager = ServiceLocator.stores,
tapToPayBadgePromotionChecker: TapToPayBadgePromotionChecker) {
self.viewModel = HubMenuViewModel(siteID: siteID,
tapToPayBadgePromotionChecker: tapToPayBadgePromotionChecker)
tapToPayBadgePromotionChecker: tapToPayBadgePromotionChecker,
stores: stores)

self.tapToPayBadgePromotionChecker = tapToPayBadgePromotionChecker
super.init(rootView: HubMenu(viewModel: viewModel))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ private extension HubMenuViewModel {
func createCardPresentPaymentService() {
Task {
self.cardPresentPaymentService = await CardPresentPaymentService(siteID: siteID,
stores: stores,
collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker)
}
}
Expand Down
19 changes: 16 additions & 3 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -665,14 +665,20 @@ private extension MainTabBarController {
// Starts observing the POS eligibility state.
posEligibilityCheckTask = Task { @MainActor [weak self] in
guard let self, let posEligibilityChecker else { return }
let eligibility = await posEligibilityChecker.checkEligibility()
let isPOSTabVisible = eligibility == .eligible
async let initialVisibility = posEligibilityChecker.checkInitialVisibility()
Copy link
Contributor

Choose a reason for hiding this comment

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

There's still a slight delay for the Point of Sale tab to appear.

updateTabViewControllers(isPOSTabVisible: posEligibilityChecker.checkInitialVisibility())
Simulator.Screen.Recording.-.iPad.Air.13-inch.M3.-.2025-06-16.at.15.21.11.mp4

If we look at checkInitialVisibility implementation, it could be synchronous, and we could call it outside the posEligibilityCheckTask, allowing for the tab to appear as soon as possible. It would require getting around dispatching AppSettingsStore and accessing the data directly, but other than that, it would allow for a smoother appearance:

Simulator.Screen.Recording.-.iPad.Air.13-inch.M3.-.2025-06-16.at.15.31.22.mp4

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's interesting that I didn't observe the slight delay in iOS 18.4 simulator, as in the screencast 1:06. But then I tried the same simulator type in iOS 18.5, and I see the delay now. I tried the device type as in my original screencast but in iOS 18.5 and see the same, so likely a behavior difference (probably about the timing of Task) between iOS versions. I'm looking into using a service for loading the initial visibility to make it sync.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, I updated the PR with a few commits to move the async initial value to a synchronous call from a new service in Yosemite POSEligibilityService behind a protocol for unit testing. Also moved the AppSettingsAction/store changes to the service for consistency. Please see if the delay is fixed after the initial load when you get a chance, thanks!

async let eligibility = posEligibilityChecker.checkEligibility()
updateTabViewControllers(isPOSTabVisible: await initialVisibility)
let isPOSTabVisible = await eligibility == .eligible
setPOSTabVisibilityToAppSettings(siteID: siteID, isPOSTabVisible: isPOSTabVisible)
updateTabViewControllers(isPOSTabVisible: isPOSTabVisible)
viewModel.loadHubMenuTabBadge()
}
}

func updateTabViewControllers(isPOSTabVisible: Bool) {
guard isPOSTabVisible != self.isPOSTabVisible || (viewControllers?.count ?? 0) == 0 else {
return
}
var controllers = [UIViewController]()
let tabs = WooTab.visibleTabs(isPOSTabVisible: isPOSTabVisible)
tabs.forEach { tab in
Expand Down Expand Up @@ -736,7 +742,8 @@ private extension MainTabBarController {
posTabCoordinator = POSTabCoordinator(
siteID: siteID,
tabContainerController: posContainerController,
viewControllerToPresent: self
viewControllerToPresent: self,
storesManager: stores
)

// Configure hub menu tab coordinator once per logged in session potentially with multiple sites.
Expand Down Expand Up @@ -923,6 +930,12 @@ private extension MainTabBarController {
}
}

private extension MainTabBarController {
func setPOSTabVisibilityToAppSettings(siteID: Int64, isPOSTabVisible: Bool) {
stores.dispatch(AppSettingsAction.setPOSTabVisibility(siteID: siteID, isVisible: isPOSTabVisible, date: .init()))
}
}

private extension MainTabBarController {
enum Constants {
// Used to delay a second navigation after the previous one is called,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import Foundation
@testable import WooCommerce

final class MockPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
var initialVisibility: Bool = false
var result: POSEligibilityState = .eligible

func checkInitialVisibility() async -> Bool {
initialVisibility
}

@MainActor
func checkEligibility() async -> POSEligibilityState {
result
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Combine
import Photos
import SwiftUI
import TestKit
import XCTest
@testable import WooCommerce
Expand Down Expand Up @@ -450,6 +451,78 @@ final class MainTabBarControllerTests: XCTestCase {
// Resets the tab bar controller mock at the end of the test.
TestingAppDelegate.mockTabBarController = nil
}

@available(iOS 17.0, *)
func test_pos_tab_becomes_invisible_after_being_selected_when_initially_visible_then_eligibility_changes() throws {
// Given
let featureFlagService = MockFeatureFlagService()
featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true

let mockPOSEligibilityChecker = MockAsyncPOSEligibilityChecker()
mockPOSEligibilityChecker.initialVisibility = true

let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true))

// For initializing `CardPresentPaymentService` async in `POSTabCoordinator.presentPOSView`.
stores.whenReceivingAction(ofType: CardPresentPaymentAction.self) { action in
if case let .publishCardReaderConnections(completion) = action {
completion(Just<[CardReader]>([]).eraseToAnyPublisher())
}
}

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
featureFlagService: featureFlagService,
stores: stores,
posEligibilityCheckerFactory: { _ in mockPOSEligibilityChecker })
}) else {
return
}

window.rootViewController = tabBarController

// Trigger `viewDidLoad`
XCTAssertNotNil(tabBarController.view)

// When POS tab initial visibility is set to true
stores.updateDefaultStore(storeID: 1126)

// Then POS tab is visible before eligibility check is returned
waitUntil {
tabBarController.tabRootViewControllers.count == 5
}
assertThat(tabBarController.tabRootViewController(tab: .pointOfSale, isPOSTabVisible: true),
isAnInstanceOf: POSTabViewController.self)
let posTabContainerController = try XCTUnwrap(
tabBarController.viewControllers?[WooTab.pointOfSale.visibleIndex(isPOSTabVisible: true)] as? TabContainerController
)

// When selecting POS tab
XCTAssertFalse(tabBarController.tabBarController(tabBarController, shouldSelect: posTabContainerController))

waitUntil {
posTabContainerController.presentedViewController is UIHostingController<PointOfSaleEntryPointView>
}

// When returning POS eligibility as ineligible
mockPOSEligibilityChecker.setEligibilityResult(.ineligible(reason: .featureFlagDisabled))

// Then POS tab is hidden
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)
}
}

extension MainTabBarController {
Expand Down Expand Up @@ -494,3 +567,31 @@ extension MainTabBarController {
return viewController
}
}

private final class MockAsyncPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
var initialVisibility: Bool = false
private var eligibilityResult: POSEligibilityState = .eligible
private var eligibilityContinuation: CheckedContinuation<POSEligibilityState, Never>?

func setEligibilityResult(_ result: POSEligibilityState) {
eligibilityResult = result
if let continuation = eligibilityContinuation {
eligibilityContinuation = nil
continuation.resume(returning: result)
}
}

func checkInitialVisibility() async -> Bool {
initialVisibility
}

func checkEligibility() async -> POSEligibilityState {
await withCheckedContinuation { continuation in
eligibilityContinuation = continuation
// If we already have a result, return it immediately.
if eligibilityContinuation == nil {
continuation.resume(returning: eligibilityResult)
}
}
}
}
Loading