Skip to content

Commit 932ece2

Browse files
authored
[POS as a tab i2] Enable POS tab for stores in eligible countries behind i2 feature flag (#15824)
2 parents e9b8543 + 1e2dcac commit 932ece2

File tree

9 files changed

+306
-55
lines changed

9 files changed

+306
-55
lines changed

Modules/Sources/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
9393
return false
9494
case .pointOfSaleAsATabi1:
9595
return true
96+
case .pointOfSaleAsATabi2:
97+
return buildConfig == .localDeveloper || buildConfig == .alpha
9698
case .pointOfSaleOrdersi1:
9799
return buildConfig == .localDeveloper || buildConfig == .alpha
98100
default:

Modules/Sources/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ public enum FeatureFlag: Int {
196196
///
197197
case pointOfSaleAsATabi1
198198

199+
/// Enables displaying POS as a tab in the tab bar for stores in eligible countries
200+
///
201+
case pointOfSaleAsATabi2
202+
199203
/// Enables displaying Point Of Sale details in order list and order details
200204
///
201205
case pointOfSaleOrdersi1

WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ enum POSEligibilityState: Equatable {
3838
protocol POSEntryPointEligibilityCheckerProtocol {
3939
/// Checks the initial visibility of the POS tab.
4040
func checkInitialVisibility() -> Bool
41+
/// Checks the final visibility of the POS tab.
42+
func checkVisibility() async -> Bool
4143
/// Determines whether the site is eligible for POS.
4244
func checkEligibility() async -> POSEligibilityState
4345
}
@@ -74,12 +76,11 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
7476

7577
/// Determines whether the POS entry point can be shown based on the selected store and feature gates.
7678
func checkEligibility() async -> POSEligibilityState {
77-
guard #available(iOS 17.0, *) else {
78-
return .ineligible(reason: .unsupportedIOSVersion)
79-
}
80-
81-
guard userInterfaceIdiom == .pad else {
82-
return .ineligible(reason: .notTablet)
79+
switch checkDeviceEligibility() {
80+
case .eligible:
81+
break
82+
case .ineligible(let reason):
83+
return .ineligible(reason: reason)
8384
}
8485

8586
async let siteSettingsEligibility = checkSiteSettingsEligibility()
@@ -110,8 +111,61 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
110111
return .ineligible(reason: reason)
111112
}
112113
}
114+
115+
/// Checks the final visibility of the POS tab.
116+
func checkVisibility() async -> Bool {
117+
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
118+
return await checkVisibilityBasedOnCountryAndRemoteFeatureFlag()
119+
} else {
120+
let eligibility = await checkEligibility()
121+
return eligibility == .eligible
122+
}
123+
}
124+
}
125+
126+
private extension POSTabEligibilityChecker {
127+
func checkDeviceEligibility() -> POSEligibilityState {
128+
guard #available(iOS 17.0, *) else {
129+
return .ineligible(reason: .unsupportedIOSVersion)
130+
}
131+
132+
guard userInterfaceIdiom == .pad else {
133+
return .ineligible(reason: .notTablet)
134+
}
135+
136+
return .eligible
137+
}
138+
139+
func checkVisibilityBasedOnCountryAndRemoteFeatureFlag() async -> Bool {
140+
guard checkDeviceEligibility() == .eligible else {
141+
return false
142+
}
143+
144+
async let siteSettingsEligibility = checkSiteSettingsEligibility()
145+
async let featureFlagEligibility = checkRemoteFeatureEligibility()
146+
147+
switch await siteSettingsEligibility {
148+
case .eligible:
149+
break
150+
case let .ineligible(reason):
151+
if reason == .unsupportedCurrency {
152+
break
153+
} else {
154+
return false
155+
}
156+
}
157+
158+
switch await featureFlagEligibility {
159+
case .eligible:
160+
return true
161+
case .ineligible:
162+
return false
163+
}
164+
}
113165
}
114166

167+
// MARK: - WC Plugin Related Eligibility Check
168+
115169
private extension POSTabEligibilityChecker {
116170
func checkPluginEligibility() async -> POSEligibilityState {
117171
let wcPlugin = await fetchWooCommercePlugin(siteID: siteID)
@@ -157,6 +211,8 @@ private extension POSTabEligibilityChecker {
157211
}
158212
}
159213

214+
// MARK: - Site Settings Related Eligibility Check
215+
160216
private extension POSTabEligibilityChecker {
161217
func checkSiteSettingsEligibility() async -> POSEligibilityState {
162218
// Waits for the first site settings that matches the given site ID.
@@ -202,6 +258,8 @@ private extension POSTabEligibilityChecker {
202258
}
203259
}
204260

261+
// MARK: - Remote Feature Flag Eligibility Check
262+
205263
private extension POSTabEligibilityChecker {
206264
@MainActor
207265
func checkRemoteFeatureEligibility() async -> POSEligibilityState {

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,8 +670,7 @@ private extension MainTabBarController {
670670
// Starts observing the POS eligibility state.
671671
posEligibilityCheckTask = Task { @MainActor [weak self] in
672672
guard let self, let posEligibilityChecker = self.posEligibilityChecker else { return }
673-
let eligibility = await posEligibilityChecker.checkEligibility()
674-
let isPOSTabVisible = eligibility == .eligible
673+
let isPOSTabVisible = await posEligibilityChecker.checkVisibility()
675674
analytics.track(.pointOfSaleTabVisibilityChecked, withProperties: ["is_visible": isPOSTabVisible])
676675
cachePOSTabVisibility(siteID: siteID, isPOSTabVisible: isPOSTabVisible)
677676
updateTabViewControllers(isPOSTabVisible: isPOSTabVisible)

WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ final class MockFeatureFlagService: FeatureFlagService {
1414
var isSubscriptionsInOrderCreationCustomersEnabled: Bool
1515
var isSubscriptionsInOrderCreationUIEnabled: Bool
1616
var isPointOfSaleEnabled: Bool
17+
var isPointOfSaleAsATabi2Enabled: Bool
1718
var googleAdsCampaignCreationOnWebView: Bool
1819
var blazeEvergreenCampaigns: Bool
1920
var blazeCampaignObjective: Bool
@@ -37,6 +38,7 @@ final class MockFeatureFlagService: FeatureFlagService {
3738
isSubscriptionsInOrderCreationCustomersEnabled: Bool = false,
3839
isSubscriptionsInOrderCreationUIEnabled: Bool = false,
3940
isPointOfSaleEnabled: Bool = false,
41+
isPointOfSaleAsATabi2Enabled: Bool = false,
4042
googleAdsCampaignCreationOnWebView: Bool = false,
4143
blazeEvergreenCampaigns: Bool = false,
4244
blazeCampaignObjective: Bool = false,
@@ -58,6 +60,7 @@ final class MockFeatureFlagService: FeatureFlagService {
5860
self.isSubscriptionsInOrderCreationCustomersEnabled = isSubscriptionsInOrderCreationCustomersEnabled
5961
self.isSubscriptionsInOrderCreationUIEnabled = isSubscriptionsInOrderCreationUIEnabled
6062
self.isPointOfSaleEnabled = isPointOfSaleEnabled
63+
self.isPointOfSaleAsATabi2Enabled = isPointOfSaleAsATabi2Enabled
6164
self.googleAdsCampaignCreationOnWebView = googleAdsCampaignCreationOnWebView
6265
self.blazeEvergreenCampaigns = blazeEvergreenCampaigns
6366
self.blazeCampaignObjective = blazeCampaignObjective
@@ -101,6 +104,8 @@ final class MockFeatureFlagService: FeatureFlagService {
101104
return isSubscriptionsInOrderCreationUIEnabled
102105
case .pointOfSale:
103106
return isPointOfSaleEnabled
107+
case .pointOfSaleAsATabi2:
108+
return isPointOfSaleAsATabi2Enabled
104109
case .googleAdsCampaignCreationOnWebView:
105110
return googleAdsCampaignCreationOnWebView
106111
case .blazeEvergreenCampaigns:

WooCommerce/WooCommerceTests/Mocks/MockPOSEligibilityChecker.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import Foundation
33

44
final class MockPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
55
var initialVisibility: Bool = false
6-
var result: POSEligibilityState = .eligible
6+
var visibility: Bool = false
7+
var eligibility: POSEligibilityState = .eligible
78

89
func checkInitialVisibility() -> Bool {
910
initialVisibility
1011
}
1112

13+
@MainActor
14+
func checkVisibility() async -> Bool {
15+
visibility
16+
}
17+
1218
@MainActor
1319
func checkEligibility() async -> POSEligibilityState {
14-
result
20+
eligibility
1521
}
1622
}

WooCommerce/WooCommerceTests/ViewRelated/MainTabBarController+TabsTests.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ final class MainTabBarController_TabsTests: XCTestCase {
4242
isAnInstanceOf: HubMenuViewController.self)
4343
}
4444

45-
func test_tab_view_controllers_include_pos_tab_when_pos_is_eligible() throws {
45+
func test_tab_view_controllers_include_pos_tab_when_pos_tab_is_visible() throws {
4646
// Given
4747
let featureFlagService = MockFeatureFlagService()
4848
featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true
4949

5050
let mockPOSEligibilityChecker = MockPOSEligibilityChecker()
51-
mockPOSEligibilityChecker.result = .eligible
51+
mockPOSEligibilityChecker.visibility = true
5252

5353
let storesManager = MockStoresManager(sessionManager: .makeForTesting())
5454

@@ -85,13 +85,13 @@ final class MainTabBarController_TabsTests: XCTestCase {
8585
isAnInstanceOf: HubMenuViewController.self)
8686
}
8787

88-
func test_tab_view_controllers_exclude_pos_tab_when_pos_is_not_eligible() throws {
88+
func test_tab_view_controllers_exclude_pos_tab_when_pos_tab_is_not_visible() throws {
8989
// Given
9090
let featureFlagService = MockFeatureFlagService()
9191
featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true
9292

9393
let mockPOSEligibilityChecker = MockPOSEligibilityChecker()
94-
mockPOSEligibilityChecker.result = .ineligible(reason: .notTablet)
94+
mockPOSEligibilityChecker.visibility = false
9595

9696
let storesManager = MockStoresManager(sessionManager: .makeForTesting())
9797

@@ -132,7 +132,7 @@ final class MainTabBarController_TabsTests: XCTestCase {
132132
featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = false
133133

134134
let mockPOSEligibilityChecker = MockPOSEligibilityChecker()
135-
mockPOSEligibilityChecker.result = .eligible
135+
mockPOSEligibilityChecker.visibility = true
136136

137137
let storesManager = MockStoresManager(sessionManager: .makeForTesting())
138138

@@ -165,13 +165,13 @@ final class MainTabBarController_TabsTests: XCTestCase {
165165
isAnInstanceOf: HubMenuViewController.self)
166166
}
167167

168-
func test_tab_view_controllers_do_not_change_when_pos_eligibility_changes() throws {
168+
func test_tab_view_controllers_do_not_change_when_pos_visibility_changes() throws {
169169
// Given
170170
let featureFlagService = MockFeatureFlagService()
171171
featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true
172172

173173
let mockPOSEligibilityChecker = MockPOSEligibilityChecker()
174-
mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled)
174+
mockPOSEligibilityChecker.visibility = false
175175

176176
let storesManager = MockStoresManager(sessionManager: .makeForTesting())
177177

@@ -196,7 +196,7 @@ final class MainTabBarController_TabsTests: XCTestCase {
196196
}
197197

198198
// When - change POS eligibility
199-
mockPOSEligibilityChecker.result = .eligible
199+
mockPOSEligibilityChecker.visibility = true
200200

201201
// Then tabs remain the same
202202
XCTAssertEqual(tabBarController.tabRootViewControllers.count, 4)

WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ final class MainTabBarControllerTests: XCTestCase {
7676

7777
// Hides POS tab.
7878
let mockPOSEligibilityChecker = MockPOSEligibilityChecker()
79-
mockPOSEligibilityChecker.result = .ineligible(reason: .featureFlagDisabled)
79+
mockPOSEligibilityChecker.visibility = false
8080

8181
let storesManager = MockStoresManager(sessionManager: .testingInstance)
8282
// Reset `receivedActions`
@@ -504,8 +504,8 @@ final class MainTabBarControllerTests: XCTestCase {
504504
posTabContainerController.presentedViewController is UIHostingController<PointOfSaleEntryPointView>
505505
}
506506

507-
// When returning POS eligibility as ineligible
508-
mockPOSEligibilityChecker.setEligibilityResult(.ineligible(reason: .featureFlagDisabled))
507+
// When POS tab becomes invisible
508+
mockPOSEligibilityChecker.setVisibilityResult(false)
509509

510510
// Then POS tab is hidden
511511
waitUntil {
@@ -551,7 +551,7 @@ final class MainTabBarControllerTests: XCTestCase {
551551

552552
// When POS tab initial visibility is set to true
553553
stores.updateDefaultStore(storeID: 1216)
554-
mockPOSEligibilityChecker.setEligibilityResult(.eligible)
554+
mockPOSEligibilityChecker.setVisibilityResult(true)
555555

556556
waitUntil {
557557
tabBarController.tabRootViewControllers.count == 5
@@ -567,7 +567,7 @@ final class MainTabBarControllerTests: XCTestCase {
567567
featureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleAsATabi1] = true
568568

569569
let mockPOSEligibilityChecker = MockPOSEligibilityChecker()
570-
mockPOSEligibilityChecker.result = .eligible
570+
mockPOSEligibilityChecker.visibility = true
571571

572572
let storesManager = MockStoresManager(sessionManager: .makeForTesting())
573573

@@ -642,9 +642,19 @@ extension MainTabBarController {
642642

643643
private final class MockAsyncPOSEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
644644
var initialVisibility: Bool = false
645+
private var visibilityResult: Bool?
646+
private var visibilityContinuation: CheckedContinuation<Bool, Never>?
645647
private var eligibilityResult: POSEligibilityState?
646648
private var eligibilityContinuation: CheckedContinuation<POSEligibilityState, Never>?
647649

650+
func setVisibilityResult(_ result: Bool) {
651+
visibilityResult = result
652+
if let continuation = visibilityContinuation {
653+
visibilityContinuation = nil
654+
continuation.resume(returning: result)
655+
}
656+
}
657+
648658
func setEligibilityResult(_ result: POSEligibilityState) {
649659
eligibilityResult = result
650660
if let continuation = eligibilityContinuation {
@@ -657,6 +667,19 @@ private final class MockAsyncPOSEligibilityChecker: POSEntryPointEligibilityChec
657667
initialVisibility
658668
}
659669

670+
func checkVisibility() async -> Bool {
671+
if let visibilityResult {
672+
return visibilityResult
673+
}
674+
return await withCheckedContinuation { continuation in
675+
visibilityContinuation = continuation
676+
// If we already have a result, return it immediately.
677+
if visibilityContinuation == nil {
678+
continuation.resume(returning: visibilityResult ?? true)
679+
}
680+
}
681+
}
682+
660683
func checkEligibility() async -> POSEligibilityState {
661684
if let eligibilityResult {
662685
return eligibilityResult

0 commit comments

Comments
 (0)