Skip to content

Commit da958db

Browse files
authored
[POS as a tab i2] Move i1 eligibility checker to LegacyPOSTabEligibilityChecker with i1 only implementation (#15864)
2 parents 481014b + 2a024c3 commit da958db

File tree

5 files changed

+729
-6
lines changed

5 files changed

+729
-6
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import Foundation
2+
import UIKit
3+
import class WooFoundation.CurrencySettings
4+
import enum WooFoundation.CountryCode
5+
import enum WooFoundation.CurrencyCode
6+
import protocol Experiments.FeatureFlagService
7+
import struct Yosemite.SiteSetting
8+
import protocol Yosemite.POSEligibilityServiceProtocol
9+
import protocol Yosemite.StoresManager
10+
import class Yosemite.POSEligibilityService
11+
import struct Yosemite.SystemPlugin
12+
import enum Yosemite.FeatureFlagAction
13+
import enum Yosemite.SettingAction
14+
import protocol Yosemite.PluginsServiceProtocol
15+
import class Yosemite.PluginsService
16+
17+
/// Legacy enum containing POS invisible reasons + POSIneligibleReason cases for i1.
18+
private enum LegacyPOSIneligibleReason: Equatable {
19+
case notTablet
20+
case unsupportedIOSVersion
21+
case unsupportedWooCommerceVersion(minimumVersion: String)
22+
case siteSettingsNotAvailable
23+
case wooCommercePluginNotFound
24+
case featureFlagDisabled
25+
case featureSwitchDisabled
26+
case featureSwitchSyncFailure
27+
case unsupportedCountry(supportedCountries: [CountryCode])
28+
case unsupportedCurrency(supportedCurrencies: [CurrencyCode])
29+
case selfDeallocated
30+
}
31+
32+
/// Legacy POS eligibility state for i1.
33+
private enum LegacyPOSEligibilityState: Equatable {
34+
case eligible
35+
case ineligible(reason: LegacyPOSIneligibleReason)
36+
}
37+
38+
/// POS tab eligibility checker for i1. Will be replaced by `POSTabEligibilityCheckerI2` when removing `pointOfSaleAsATabi2` feature flag.
39+
final class LegacyPOSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
40+
private let siteID: Int64
41+
private let userInterfaceIdiom: UIUserInterfaceIdiom
42+
private let siteSettings: SelectedSiteSettingsProtocol
43+
private let pluginsService: PluginsServiceProtocol
44+
private let eligibilityService: POSEligibilityServiceProtocol
45+
private let stores: StoresManager
46+
private let featureFlagService: FeatureFlagService
47+
48+
init(siteID: Int64,
49+
userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom,
50+
siteSettings: SelectedSiteSettingsProtocol = ServiceLocator.selectedSiteSettings,
51+
pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager),
52+
eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(),
53+
stores: StoresManager = ServiceLocator.stores,
54+
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
55+
self.siteID = siteID
56+
self.userInterfaceIdiom = userInterfaceIdiom
57+
self.siteSettings = siteSettings
58+
self.pluginsService = pluginsService
59+
self.eligibilityService = eligibilityService
60+
self.stores = stores
61+
self.featureFlagService = featureFlagService
62+
}
63+
64+
/// Checks the initial visibility of the POS tab without dependance on network requests.
65+
func checkInitialVisibility() -> Bool {
66+
eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false
67+
}
68+
69+
/// Determines whether the POS entry point can be shown based on the selected store and feature gates.
70+
func checkEligibility() async -> POSEligibilityState {
71+
.eligible
72+
}
73+
74+
private func checkI1Eligibility() async -> LegacyPOSEligibilityState {
75+
switch checkDeviceEligibility() {
76+
case .eligible:
77+
break
78+
case .ineligible(let reason):
79+
return .ineligible(reason: reason)
80+
}
81+
82+
async let siteSettingsEligibility = checkSiteSettingsEligibility()
83+
async let featureFlagEligibility = checkRemoteFeatureEligibility()
84+
async let pluginEligibility = checkPluginEligibility()
85+
86+
// Checks site settings first since it's likely to complete fastest.
87+
switch await siteSettingsEligibility {
88+
case .eligible:
89+
break
90+
case .ineligible(let reason):
91+
return .ineligible(reason: reason)
92+
}
93+
94+
// Then checks feature flag.
95+
switch await featureFlagEligibility {
96+
case .eligible:
97+
break
98+
case .ineligible(let reason):
99+
return .ineligible(reason: reason)
100+
}
101+
102+
// Finally checks plugin eligibility.
103+
switch await pluginEligibility {
104+
case .eligible:
105+
return .eligible
106+
case .ineligible(let reason):
107+
return .ineligible(reason: reason)
108+
}
109+
}
110+
111+
/// Checks the final visibility of the POS tab.
112+
func checkVisibility() async -> Bool {
113+
let eligibility = await checkI1Eligibility()
114+
return eligibility == .eligible
115+
}
116+
}
117+
118+
private extension LegacyPOSTabEligibilityChecker {
119+
func checkDeviceEligibility() -> LegacyPOSEligibilityState {
120+
guard #available(iOS 17.0, *) else {
121+
return .ineligible(reason: .unsupportedIOSVersion)
122+
}
123+
124+
guard userInterfaceIdiom == .pad else {
125+
return .ineligible(reason: .notTablet)
126+
}
127+
128+
return .eligible
129+
}
130+
}
131+
132+
// MARK: - WC Plugin Related Eligibility Check
133+
134+
private extension LegacyPOSTabEligibilityChecker {
135+
func checkPluginEligibility() async -> LegacyPOSEligibilityState {
136+
let wcPlugin = await fetchWooCommercePlugin(siteID: siteID)
137+
138+
guard VersionHelpers.isVersionSupported(version: wcPlugin.version,
139+
minimumRequired: Constants.wcPluginMinimumVersion) else {
140+
return .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersion))
141+
}
142+
143+
// For versions below 10.0.0, the feature is enabled by default.
144+
let isFeatureSwitchSupported = VersionHelpers.isVersionSupported(version: wcPlugin.version,
145+
minimumRequired: Constants.wcPluginMinimumVersionWithFeatureSwitch,
146+
includesDevAndBetaVersions: true)
147+
if !isFeatureSwitchSupported {
148+
return .eligible
149+
}
150+
151+
// For versions that support the feature switch, checks if the feature switch is enabled.
152+
return await checkFeatureSwitchEnabled(siteID: siteID)
153+
}
154+
155+
@MainActor
156+
func fetchWooCommercePlugin(siteID: Int64) async -> SystemPlugin {
157+
await pluginsService.waitForPluginInStorage(siteID: siteID, pluginName: Constants.wcPluginName, isActive: true)
158+
}
159+
160+
@MainActor
161+
func checkFeatureSwitchEnabled(siteID: Int64) async -> LegacyPOSEligibilityState {
162+
await withCheckedContinuation { [weak self] continuation in
163+
guard let self else {
164+
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
165+
}
166+
let action = SettingAction.isFeatureEnabled(siteID: siteID, feature: .pointOfSale) { result in
167+
switch result {
168+
case .success(let isEnabled):
169+
continuation.resume(returning: isEnabled ? .eligible : .ineligible(reason: .featureSwitchDisabled))
170+
case .failure:
171+
continuation.resume(returning: .ineligible(reason: .featureSwitchSyncFailure))
172+
}
173+
}
174+
stores.dispatch(action)
175+
}
176+
}
177+
}
178+
179+
// MARK: - Site Settings Related Eligibility Check
180+
181+
private extension LegacyPOSTabEligibilityChecker {
182+
func checkSiteSettingsEligibility() async -> LegacyPOSEligibilityState {
183+
// Waits for the first site settings that matches the given site ID.
184+
let siteSettings = await waitForSiteSettingsRefresh()
185+
guard siteSettings.isNotEmpty else {
186+
return .ineligible(reason: .siteSettingsNotAvailable)
187+
}
188+
189+
// Conditions that can change if site settings are synced during the lifetime.
190+
let countryCode = SiteAddress(siteSettings: siteSettings).countryCode
191+
let currencyCode = CurrencySettings(siteSettings: siteSettings).currencyCode
192+
193+
return isEligibleFromCountryAndCurrencyCode(countryCode: countryCode, currencyCode: currencyCode)
194+
}
195+
196+
func waitForSiteSettingsRefresh() async -> [SiteSetting] {
197+
for await siteSettings in siteSettings.settingsStream.values {
198+
guard siteSettings.siteID == siteID, siteSettings.settings.isNotEmpty, siteSettings.source != .initialLoad else {
199+
continue
200+
}
201+
return siteSettings.settings
202+
}
203+
// If we get here, the stream completed without yielding any values for our site ID which is unexpected.
204+
return []
205+
}
206+
207+
func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> LegacyPOSEligibilityState {
208+
let supportedCountries: [CountryCode] = [.US, .GB]
209+
let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD],
210+
.GB: [.GBP]]
211+
212+
// Checks country first.
213+
guard supportedCountries.contains(countryCode) else {
214+
return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries))
215+
}
216+
217+
let supportedCurrenciesForCountry = supportedCurrencies[countryCode] ?? []
218+
guard supportedCurrenciesForCountry.contains(currencyCode) else {
219+
return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrenciesForCountry))
220+
}
221+
return .eligible
222+
}
223+
}
224+
225+
// MARK: - Remote Feature Flag Eligibility Check
226+
227+
private extension LegacyPOSTabEligibilityChecker {
228+
@MainActor
229+
func checkRemoteFeatureEligibility() async -> LegacyPOSEligibilityState {
230+
// Only whitelisted accounts in WPCOM have the Point of Sale remote feature flag enabled. These can be found at D159901-code
231+
// If the account is whitelisted, then the remote value takes preference over the local feature flag configuration
232+
await withCheckedContinuation { [weak self] continuation in
233+
guard let self else {
234+
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
235+
}
236+
let action = FeatureFlagAction.isRemoteFeatureFlagEnabled(.pointOfSale, defaultValue: false) { [weak self] result in
237+
guard let self else {
238+
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
239+
}
240+
switch result {
241+
case true:
242+
// The site is whitelisted.
243+
continuation.resume(returning: .eligible)
244+
case false:
245+
// When the site is not whitelisted, check the local feature flag configuration.
246+
let localFeatureFlag = featureFlagService.isFeatureFlagEnabled(.pointOfSale)
247+
continuation.resume(returning: localFeatureFlag ? .eligible : .ineligible(reason: .featureFlagDisabled))
248+
}
249+
}
250+
self.stores.dispatch(action)
251+
}
252+
}
253+
}
254+
255+
private extension LegacyPOSTabEligibilityChecker {
256+
enum Constants {
257+
static let wcPluginName = "WooCommerce"
258+
static let wcPluginMinimumVersion = "9.6.0-beta"
259+
static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0"
260+
}
261+
}

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,20 +149,29 @@ final class MainTabBarController: UITabBarController {
149149
self.analytics = analytics
150150
self.stores = stores
151151
self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in
152-
POSTabEligibilityChecker(siteID: siteID)
152+
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
153+
POSTabEligibilityChecker(siteID: siteID)
154+
} else {
155+
LegacyPOSTabEligibilityChecker(siteID: siteID)
156+
}
153157
}
154158
self.posEligibilityService = posEligibilityService
155159
super.init(coder: coder)
156160
}
157161

158162
required init?(coder: NSCoder) {
159-
self.featureFlagService = ServiceLocator.featureFlagService
163+
let featureFlagService = ServiceLocator.featureFlagService
164+
self.featureFlagService = featureFlagService
160165
self.noticePresenter = ServiceLocator.noticePresenter
161166
self.productImageUploader = ServiceLocator.productImageUploader
162167
self.analytics = ServiceLocator.analytics
163168
self.stores = ServiceLocator.stores
164169
self.posEligibilityCheckerFactory = { siteID in
165-
POSTabEligibilityChecker(siteID: siteID)
170+
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
171+
POSTabEligibilityChecker(siteID: siteID)
172+
} else {
173+
LegacyPOSTabEligibilityChecker(siteID: siteID)
174+
}
166175
}
167176
self.posEligibilityService = POSEligibilityService()
168177
super.init(coder: coder)

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,8 @@
516516
02B653AC2429F7BF00A9C839 /* MockTaxClassStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */; };
517517
02B7C4F62BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */; };
518518
02B8650F24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */; };
519+
02B881832E1857E0009375F5 /* LegacyPOSTabEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */; };
520+
02B881852E18586E009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */; };
519521
02B8E4192DFBC218001D01FD /* MainTabBarController+TabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */; };
520522
02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */; };
521523
02B9243F2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */; };
@@ -3672,6 +3674,8 @@
36723674
02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTaxClassStoresManager.swift; sourceTree = "<group>"; };
36733675
02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleCustomerCardHeaderView.swift; sourceTree = "<group>"; };
36743676
02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+SwiftUIPreviewHelpers.swift"; sourceTree = "<group>"; };
3677+
02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPOSTabEligibilityChecker.swift; sourceTree = "<group>"; };
3678+
02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPOSTabEligibilityCheckerTests.swift; sourceTree = "<group>"; };
36753679
02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainTabBarController+TabsTests.swift"; sourceTree = "<group>"; };
36763680
02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSEligibilityChecker.swift; sourceTree = "<group>"; };
36773681
02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift; sourceTree = "<group>"; };
@@ -6788,6 +6792,7 @@
67886792
children = (
67896793
023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */,
67906794
0277889D2DF928E3006F5B8C /* POSTabEligibilityCheckerTests.swift */,
6795+
02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */,
67916796
);
67926797
path = POS;
67936798
sourceTree = "<group>";
@@ -7755,6 +7760,7 @@
77557760
children = (
77567761
026B2D162DF92290005B8CAA /* POSTabEligibilityChecker.swift */,
77577762
02E4A0822BFB1C4F006D4F87 /* POSEligibilityChecker.swift */,
7763+
02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */,
77587764
);
77597765
path = POS;
77607766
sourceTree = "<group>";
@@ -16619,6 +16625,7 @@
1661916625
AE7C957D27C3F187007E8E12 /* FeeOrDiscountLineDetailsViewModel.swift in Sources */,
1662016626
4520A15C2721B2A9001FA573 /* FilterOrderListViewModel.swift in Sources */,
1662116627
B582F95920FFCEAA0060934A /* UITableViewHeaderFooterView+Helpers.swift in Sources */,
16628+
02B881832E1857E0009375F5 /* LegacyPOSTabEligibilityChecker.swift in Sources */,
1662216629
DA41043A2C247B6900E8456A /* PointOfSalePreviewOrderController.swift in Sources */,
1662316630
20F6A46C2DE5FCEF0066D8CB /* POSItemFetchAnalytics.swift in Sources */,
1662416631
B933CCB02AA6220E00938F3F /* TaxRateRow.swift in Sources */,
@@ -17337,6 +17344,7 @@
1733717344
02A9A496244D84AB00757B99 /* ProductsSortOrderBottomSheetListSelectorCommandTests.swift in Sources */,
1733817345
B9B6DEF1283F8EB100901FB7 /* SitePluginsURLTests.swift in Sources */,
1733917346
6891C3662D364C1A00B5B48C /* CollectCashViewHelperTests.swift in Sources */,
17347+
02B881852E18586E009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift in Sources */,
1734017348
D83F5935225B3CDD00626E75 /* DatePickerTableViewCellTests.swift in Sources */,
1734117349
AEB6903729770B1D00872FE0 /* ProductListViewModelTests.swift in Sources */,
1734217350
03B9E52B2A1505A7005C77F5 /* TapToPayReconnectionControllerTests.swift in Sources */,

WooCommerce/WooCommerceTests/POS/Controllers/POSEntryPointControllerTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import Testing
33

44
struct POSEntryPointControllerTests {
55
@available(iOS 17.0, *)
6-
@Test func eligibilityState_is_set_to_eligible_when_i2_feature_is_disabled() async throws {
6+
@Test func eligibilityState_is_always_eligible_when_i2_feature_is_disabled_regardless_of_eligibility_checker() async throws {
77
// Given
88
let mockEligibilityChecker = MockPOSEligibilityChecker()
9-
mockEligibilityChecker.eligibility = .ineligible(reason: .notTablet)
9+
mockEligibilityChecker.eligibility = .ineligible(reason: .unsupportedIOSVersion)
1010
let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false)
1111

1212
// When
@@ -23,7 +23,7 @@ struct POSEntryPointControllerTests {
2323
@Test func eligibilityState_is_set_to_ineligible_when_i2_feature_is_enabled_and_checker_returns_ineligible() async throws {
2424
// Given
2525
let mockEligibilityChecker = MockPOSEligibilityChecker()
26-
let expectedState = POSEligibilityState.ineligible(reason: .notTablet)
26+
let expectedState = POSEligibilityState.ineligible(reason: .unsupportedIOSVersion)
2727
mockEligibilityChecker.eligibility = expectedState
2828
let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true)
2929

0 commit comments

Comments
 (0)