Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import Foundation
import UIKit
import class WooFoundation.CurrencySettings
import enum WooFoundation.CountryCode
import enum WooFoundation.CurrencyCode
import protocol Experiments.FeatureFlagService
import struct Yosemite.SiteSetting
import protocol Yosemite.POSEligibilityServiceProtocol
import protocol Yosemite.StoresManager
import class Yosemite.POSEligibilityService
import struct Yosemite.SystemPlugin
import enum Yosemite.FeatureFlagAction
import enum Yosemite.SettingAction
import protocol Yosemite.PluginsServiceProtocol
import class Yosemite.PluginsService

/// Legacy enum containing POS invisible reasons + POSIneligibleReason cases for i1.
private enum LegacyPOSIneligibleReason: Equatable {
case notTablet
case unsupportedIOSVersion
case unsupportedWooCommerceVersion(minimumVersion: String)
case siteSettingsNotAvailable
case wooCommercePluginNotFound
case featureFlagDisabled
case featureSwitchDisabled
case featureSwitchSyncFailure
case unsupportedCountry(supportedCountries: [CountryCode])
case unsupportedCurrency(supportedCurrencies: [CurrencyCode])
case selfDeallocated
}

/// Legacy POS eligibility state for i1.
private enum LegacyPOSEligibilityState: Equatable {
case eligible
case ineligible(reason: LegacyPOSIneligibleReason)
}

/// POS tab eligibility checker for i1. Will be replaced by `POSTabEligibilityCheckerI2` when removing `pointOfSaleAsATabi2` feature flag.
final class LegacyPOSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
private let siteID: Int64
private let userInterfaceIdiom: UIUserInterfaceIdiom
private let siteSettings: SelectedSiteSettingsProtocol
private let pluginsService: PluginsServiceProtocol
private let eligibilityService: POSEligibilityServiceProtocol
private let stores: StoresManager
private let featureFlagService: FeatureFlagService

init(siteID: Int64,
userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom,
siteSettings: SelectedSiteSettingsProtocol = ServiceLocator.selectedSiteSettings,
pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager),
eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(),
stores: StoresManager = ServiceLocator.stores,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
self.siteID = siteID
self.userInterfaceIdiom = userInterfaceIdiom
self.siteSettings = siteSettings
self.pluginsService = pluginsService
self.eligibilityService = eligibilityService
self.stores = stores
self.featureFlagService = featureFlagService
}

/// Checks the initial visibility of the POS tab without dependance on network requests.
func checkInitialVisibility() -> Bool {
eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false
}

/// Determines whether the POS entry point can be shown based on the selected store and feature gates.
func checkEligibility() async -> POSEligibilityState {
.eligible
}

private func checkI1Eligibility() async -> LegacyPOSEligibilityState {
switch checkDeviceEligibility() {
case .eligible:
break
case .ineligible(let reason):
return .ineligible(reason: reason)
}

async let siteSettingsEligibility = checkSiteSettingsEligibility()
async let featureFlagEligibility = checkRemoteFeatureEligibility()
async let pluginEligibility = checkPluginEligibility()

// Checks site settings first since it's likely to complete fastest.
switch await siteSettingsEligibility {
case .eligible:
break
case .ineligible(let reason):
return .ineligible(reason: reason)
}

// Then checks feature flag.
switch await featureFlagEligibility {
case .eligible:
break
case .ineligible(let reason):
return .ineligible(reason: reason)
}

// Finally checks plugin eligibility.
switch await pluginEligibility {
case .eligible:
return .eligible
case .ineligible(let reason):
return .ineligible(reason: reason)
}
}

/// Checks the final visibility of the POS tab.
func checkVisibility() async -> Bool {
let eligibility = await checkI1Eligibility()
return eligibility == .eligible
}
}

private extension LegacyPOSTabEligibilityChecker {
func checkDeviceEligibility() -> LegacyPOSEligibilityState {
guard #available(iOS 17.0, *) else {
return .ineligible(reason: .unsupportedIOSVersion)
}

guard userInterfaceIdiom == .pad else {
return .ineligible(reason: .notTablet)
}

return .eligible
}
}

// MARK: - WC Plugin Related Eligibility Check

private extension LegacyPOSTabEligibilityChecker {
func checkPluginEligibility() async -> LegacyPOSEligibilityState {
let wcPlugin = await fetchWooCommercePlugin(siteID: siteID)

guard VersionHelpers.isVersionSupported(version: wcPlugin.version,
minimumRequired: Constants.wcPluginMinimumVersion) else {
return .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersion))
}

// For versions below 10.0.0, the feature is enabled by default.
let isFeatureSwitchSupported = VersionHelpers.isVersionSupported(version: wcPlugin.version,
minimumRequired: Constants.wcPluginMinimumVersionWithFeatureSwitch,
includesDevAndBetaVersions: true)
if !isFeatureSwitchSupported {
return .eligible
}

// For versions that support the feature switch, checks if the feature switch is enabled.
return await checkFeatureSwitchEnabled(siteID: siteID)
}

@MainActor
func fetchWooCommercePlugin(siteID: Int64) async -> SystemPlugin {
await pluginsService.waitForPluginInStorage(siteID: siteID, pluginName: Constants.wcPluginName, isActive: true)
}

@MainActor
func checkFeatureSwitchEnabled(siteID: Int64) async -> LegacyPOSEligibilityState {
await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
let action = SettingAction.isFeatureEnabled(siteID: siteID, feature: .pointOfSale) { result in
switch result {
case .success(let isEnabled):
continuation.resume(returning: isEnabled ? .eligible : .ineligible(reason: .featureSwitchDisabled))
case .failure:
continuation.resume(returning: .ineligible(reason: .featureSwitchSyncFailure))
}
}
stores.dispatch(action)
}
}
}

// MARK: - Site Settings Related Eligibility Check

private extension LegacyPOSTabEligibilityChecker {
func checkSiteSettingsEligibility() async -> LegacyPOSEligibilityState {
// Waits for the first site settings that matches the given site ID.
let siteSettings = await waitForSiteSettingsRefresh()
guard siteSettings.isNotEmpty else {
return .ineligible(reason: .siteSettingsNotAvailable)
}

// Conditions that can change if site settings are synced during the lifetime.
let countryCode = SiteAddress(siteSettings: siteSettings).countryCode
let currencyCode = CurrencySettings(siteSettings: siteSettings).currencyCode

return isEligibleFromCountryAndCurrencyCode(countryCode: countryCode, currencyCode: currencyCode)
}

func waitForSiteSettingsRefresh() async -> [SiteSetting] {
for await siteSettings in siteSettings.settingsStream.values {
guard siteSettings.siteID == siteID, siteSettings.settings.isNotEmpty, siteSettings.source != .initialLoad else {
continue
}
return siteSettings.settings
}
// If we get here, the stream completed without yielding any values for our site ID which is unexpected.
return []
}

func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> LegacyPOSEligibilityState {
let supportedCountries: [CountryCode] = [.US, .GB]
let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD],
.GB: [.GBP]]

// Checks country first.
guard supportedCountries.contains(countryCode) else {
return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries))
}

let supportedCurrenciesForCountry = supportedCurrencies[countryCode] ?? []
guard supportedCurrenciesForCountry.contains(currencyCode) else {
return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrenciesForCountry))
}
return .eligible
}
}

// MARK: - Remote Feature Flag Eligibility Check

private extension LegacyPOSTabEligibilityChecker {
@MainActor
func checkRemoteFeatureEligibility() async -> LegacyPOSEligibilityState {
// Only whitelisted accounts in WPCOM have the Point of Sale remote feature flag enabled. These can be found at D159901-code
// If the account is whitelisted, then the remote value takes preference over the local feature flag configuration
await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
let action = FeatureFlagAction.isRemoteFeatureFlagEnabled(.pointOfSale, defaultValue: false) { [weak self] result in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
switch result {
case true:
// The site is whitelisted.
continuation.resume(returning: .eligible)
case false:
// When the site is not whitelisted, check the local feature flag configuration.
let localFeatureFlag = featureFlagService.isFeatureFlagEnabled(.pointOfSale)
continuation.resume(returning: localFeatureFlag ? .eligible : .ineligible(reason: .featureFlagDisabled))
}
}
self.stores.dispatch(action)
}
}
}

private extension LegacyPOSTabEligibilityChecker {
enum Constants {
static let wcPluginName = "WooCommerce"
static let wcPluginMinimumVersion = "9.6.0-beta"
static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0"
}
}
15 changes: 12 additions & 3 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,20 +149,29 @@ final class MainTabBarController: UITabBarController {
self.analytics = analytics
self.stores = stores
self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in
POSTabEligibilityChecker(siteID: siteID)
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
POSTabEligibilityChecker(siteID: siteID)
} else {
LegacyPOSTabEligibilityChecker(siteID: siteID)
}
}
self.posEligibilityService = posEligibilityService
super.init(coder: coder)
}

required init?(coder: NSCoder) {
self.featureFlagService = ServiceLocator.featureFlagService
let featureFlagService = ServiceLocator.featureFlagService
self.featureFlagService = featureFlagService
self.noticePresenter = ServiceLocator.noticePresenter
self.productImageUploader = ServiceLocator.productImageUploader
self.analytics = ServiceLocator.analytics
self.stores = ServiceLocator.stores
self.posEligibilityCheckerFactory = { siteID in
POSTabEligibilityChecker(siteID: siteID)
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
POSTabEligibilityChecker(siteID: siteID)
} else {
LegacyPOSTabEligibilityChecker(siteID: siteID)
}
}
self.posEligibilityService = POSEligibilityService()
super.init(coder: coder)
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@
02B653AC2429F7BF00A9C839 /* MockTaxClassStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */; };
02B7C4F62BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */; };
02B8650F24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */; };
02B881832E1857E0009375F5 /* LegacyPOSTabEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */; };
02B881852E18586E009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */; };
02B8E4192DFBC218001D01FD /* MainTabBarController+TabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */; };
02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */; };
02B9243F2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */; };
Expand Down Expand Up @@ -3672,6 +3674,8 @@
02B653AB2429F7BF00A9C839 /* MockTaxClassStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTaxClassStoresManager.swift; sourceTree = "<group>"; };
02B7C4F52BE375D800F8E93A /* CollapsibleCustomerCardHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleCustomerCardHeaderView.swift; sourceTree = "<group>"; };
02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+SwiftUIPreviewHelpers.swift"; sourceTree = "<group>"; };
02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPOSTabEligibilityChecker.swift; sourceTree = "<group>"; };
02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPOSTabEligibilityCheckerTests.swift; sourceTree = "<group>"; };
02B8E4182DFBC218001D01FD /* MainTabBarController+TabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainTabBarController+TabsTests.swift"; sourceTree = "<group>"; };
02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSEligibilityChecker.swift; sourceTree = "<group>"; };
02B9243E2C2200D600DC75F2 /* PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentReaderUpdateFailedLowBatteryAlertViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6788,6 +6792,7 @@
children = (
023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */,
0277889D2DF928E3006F5B8C /* POSTabEligibilityCheckerTests.swift */,
02B881842E18586B009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift */,
);
path = POS;
sourceTree = "<group>";
Expand Down Expand Up @@ -7755,6 +7760,7 @@
children = (
026B2D162DF92290005B8CAA /* POSTabEligibilityChecker.swift */,
02E4A0822BFB1C4F006D4F87 /* POSEligibilityChecker.swift */,
02B881822E1857DF009375F5 /* LegacyPOSTabEligibilityChecker.swift */,
);
path = POS;
sourceTree = "<group>";
Expand Down Expand Up @@ -16619,6 +16625,7 @@
AE7C957D27C3F187007E8E12 /* FeeOrDiscountLineDetailsViewModel.swift in Sources */,
4520A15C2721B2A9001FA573 /* FilterOrderListViewModel.swift in Sources */,
B582F95920FFCEAA0060934A /* UITableViewHeaderFooterView+Helpers.swift in Sources */,
02B881832E1857E0009375F5 /* LegacyPOSTabEligibilityChecker.swift in Sources */,
DA41043A2C247B6900E8456A /* PointOfSalePreviewOrderController.swift in Sources */,
20F6A46C2DE5FCEF0066D8CB /* POSItemFetchAnalytics.swift in Sources */,
B933CCB02AA6220E00938F3F /* TaxRateRow.swift in Sources */,
Expand Down Expand Up @@ -17337,6 +17344,7 @@
02A9A496244D84AB00757B99 /* ProductsSortOrderBottomSheetListSelectorCommandTests.swift in Sources */,
B9B6DEF1283F8EB100901FB7 /* SitePluginsURLTests.swift in Sources */,
6891C3662D364C1A00B5B48C /* CollectCashViewHelperTests.swift in Sources */,
02B881852E18586E009375F5 /* LegacyPOSTabEligibilityCheckerTests.swift in Sources */,
D83F5935225B3CDD00626E75 /* DatePickerTableViewCellTests.swift in Sources */,
AEB6903729770B1D00872FE0 /* ProductListViewModelTests.swift in Sources */,
03B9E52B2A1505A7005C77F5 /* TapToPayReconnectionControllerTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import Testing

struct POSEntryPointControllerTests {
@available(iOS 17.0, *)
@Test func eligibilityState_is_set_to_eligible_when_i2_feature_is_disabled() async throws {
@Test func eligibilityState_is_always_eligible_when_i2_feature_is_disabled_regardless_of_eligibility_checker() async throws {
// Given
let mockEligibilityChecker = MockPOSEligibilityChecker()
mockEligibilityChecker.eligibility = .ineligible(reason: .notTablet)
mockEligibilityChecker.eligibility = .ineligible(reason: .unsupportedIOSVersion)
let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: false)

// When
Expand All @@ -23,7 +23,7 @@ struct POSEntryPointControllerTests {
@Test func eligibilityState_is_set_to_ineligible_when_i2_feature_is_enabled_and_checker_returns_ineligible() async throws {
// Given
let mockEligibilityChecker = MockPOSEligibilityChecker()
let expectedState = POSEligibilityState.ineligible(reason: .notTablet)
let expectedState = POSEligibilityState.ineligible(reason: .unsupportedIOSVersion)
mockEligibilityChecker.eligibility = expectedState
let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: true)

Expand Down
Loading