Skip to content

Commit bc805ac

Browse files
authored
[POS as a tab i2] Show loading & ineligible UI when tapping on the POS tab (#15834)
2 parents 591d97d + 33f29a8 commit bc805ac

File tree

11 files changed

+324
-17
lines changed

11 files changed

+324
-17
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import SwiftUI
2+
import protocol Experiments.FeatureFlagService
3+
4+
@available(iOS 17.0, *)
5+
@Observable final class POSEntryPointController {
6+
private(set) var eligibilityState: POSEligibilityState?
7+
private let posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol
8+
private let featureFlagService: FeatureFlagService
9+
10+
init(eligibilityChecker: POSEntryPointEligibilityCheckerProtocol,
11+
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
12+
self.posEligibilityChecker = eligibilityChecker
13+
self.featureFlagService = featureFlagService
14+
15+
guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) else {
16+
self.eligibilityState = .eligible
17+
return
18+
}
19+
Task { @MainActor in
20+
eligibilityState = await posEligibilityChecker.checkEligibility()
21+
}
22+
}
23+
24+
@MainActor
25+
func refreshEligibility() async throws {
26+
// TODO: WOOMOB-720 - refresh eligibility
27+
}
28+
}

WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct PointOfSaleLoadingView: View {
2121
.onDisappear {
2222
trackElapsedTimeOnDisappear()
2323
}
24+
.background(Color.posSurface)
2425
}
2526
}
2627

WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol
66
struct PointOfSaleEntryPointView: View {
77
@State private var posModel: PointOfSaleAggregateModel?
88
@StateObject private var posModalManager = POSModalManager()
9+
@State private var posEntryPointController: POSEntryPointController
910
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
1011

1112
private let onPointOfSaleModeActiveStateChange: ((Bool) -> Void)
@@ -30,7 +31,8 @@ struct PointOfSaleEntryPointView: View {
3031
collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking,
3132
searchHistoryService: POSSearchHistoryProviding,
3233
popularPurchasableItemsController: PointOfSaleItemsControllerProtocol,
33-
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol) {
34+
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol,
35+
posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol) {
3436
self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange
3537

3638
self.itemsController = itemsController
@@ -43,15 +45,25 @@ struct PointOfSaleEntryPointView: View {
4345
self.searchHistoryService = searchHistoryService
4446
self.popularPurchasableItemsController = popularPurchasableItemsController
4547
self.barcodeScanService = barcodeScanService
48+
self.posEntryPointController = POSEntryPointController(eligibilityChecker: posEligibilityChecker)
4649
}
4750

4851
var body: some View {
4952
Group {
50-
if let posModel = posModel {
51-
PointOfSaleDashboardView()
52-
.environment(posModel)
53-
} else {
53+
switch posEntryPointController.eligibilityState {
54+
case .none:
5455
PointOfSaleLoadingView()
56+
case .eligible:
57+
if let posModel = posModel {
58+
PointOfSaleDashboardView()
59+
.environment(posModel)
60+
} else {
61+
PointOfSaleLoadingView()
62+
}
63+
case let .ineligible(reason):
64+
POSIneligibleView(reason: reason, onRefresh: {
65+
try await posEntryPointController.refreshEligibility()
66+
})
5567
}
5668
}
5769
.task {
@@ -96,7 +108,8 @@ struct PointOfSaleEntryPointView: View {
96108
collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalytics(),
97109
searchHistoryService: PointOfSalePreviewHistoryService(),
98110
popularPurchasableItemsController: PointOfSalePreviewItemsController(),
99-
barcodeScanService: PointOfSalePreviewBarcodeScanService())
111+
barcodeScanService: PointOfSalePreviewBarcodeScanService(),
112+
posEligibilityChecker: POSTabEligibilityChecker(siteID: 0))
100113
}
101114

102115
#endif
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import SwiftUI
2+
3+
/// A view that displays when the Point of Sale (POS) feature is not available for the current store.
4+
/// Shows the specific reason why POS is ineligible and provides a button to re-check eligibility.
5+
struct POSIneligibleView: View {
6+
let reason: POSIneligibleReason
7+
let onRefresh: () async throws -> Void
8+
@Environment(\.dismiss) private var dismiss
9+
@State private var isLoading: Bool = false
10+
11+
var body: some View {
12+
VStack(spacing: POSSpacing.large) {
13+
HStack {
14+
Spacer()
15+
Button {
16+
dismiss()
17+
} label: {
18+
Text(Image(systemName: "xmark"))
19+
.font(POSFontStyle.posButtonSymbolLarge.font())
20+
}
21+
.foregroundColor(Color.posOnSurfaceVariantLowest)
22+
}
23+
24+
Spacer()
25+
26+
VStack(spacing: POSSpacing.medium) {
27+
Image(PointOfSaleAssets.exclamationMark.imageName)
28+
.resizable()
29+
.frame(width: POSErrorAndAlertIconSize.large.dimension,
30+
height: POSErrorAndAlertIconSize.large.dimension)
31+
32+
Text(reasonText)
33+
.font(POSFontStyle.posHeadingBold.font())
34+
.multilineTextAlignment(.center)
35+
.foregroundColor(Color.posOnSurface)
36+
37+
Button {
38+
Task { @MainActor in
39+
do {
40+
isLoading = true
41+
try await onRefresh()
42+
isLoading = false
43+
} catch {
44+
// TODO-jc: handle error if needed, e.g., show an error message
45+
print("Error refreshing eligibility: \(error)")
46+
isLoading = false
47+
}
48+
}
49+
} label: {
50+
Text(Localization.refreshEligibility)
51+
}
52+
.buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isLoading))
53+
}
54+
55+
Spacer()
56+
}
57+
.padding(POSPadding.large)
58+
}
59+
60+
private var reasonText: String {
61+
switch reason {
62+
case .notTablet:
63+
return NSLocalizedString("pos.ineligible.reason.notTablet",
64+
value: "POS is only available on iPad.",
65+
comment: "Ineligible reason: not a tablet")
66+
case .unsupportedIOSVersion:
67+
return NSLocalizedString("pos.ineligible.reason.unsupportedIOSVersion",
68+
value: "POS requires a newer version of iOS 17 and above.",
69+
comment: "Ineligible reason: iOS version too low")
70+
case .unsupportedWooCommerceVersion:
71+
return NSLocalizedString("pos.ineligible.reason.unsupportedWooCommerceVersion",
72+
value: "Please update WooCommerce plugin to use POS.",
73+
comment: "Ineligible reason: WooCommerce version too low")
74+
case .wooCommercePluginNotFound:
75+
return NSLocalizedString("pos.ineligible.reason.wooCommercePluginNotFound",
76+
value: "WooCommerce plugin not found.",
77+
comment: "Ineligible reason: plugin missing")
78+
case .featureSwitchDisabled:
79+
return NSLocalizedString("pos.ineligible.reason.featureSwitchDisabled",
80+
value: "POS feature is not enabled for your store.",
81+
comment: "Ineligible reason: feature switch off")
82+
case .featureSwitchSyncFailure:
83+
return NSLocalizedString("pos.ineligible.reason.featureSwitchSyncFailure",
84+
value: "Could not verify POS feature status.",
85+
comment: "Ineligible reason: feature switch sync failed")
86+
case .unsupportedCountry:
87+
return NSLocalizedString("pos.ineligible.reason.unsupportedCountry",
88+
value: "POS is not available in your country.",
89+
comment: "Ineligible reason: country not supported")
90+
case .unsupportedCurrency:
91+
return NSLocalizedString("pos.ineligible.reason.unsupportedCurrency",
92+
value: "POS is not available for your store's currency.",
93+
comment: "Ineligible reason: currency not supported")
94+
case .siteSettingsNotAvailable:
95+
return NSLocalizedString("pos.ineligible.reason.siteSettingsNotAvailable",
96+
value: "Unable to load store settings for POS.",
97+
comment: "Ineligible reason: site settings unavailable")
98+
case .featureFlagDisabled:
99+
return NSLocalizedString("pos.ineligible.reason.featureFlagDisabled",
100+
value: "POS feature is currently disabled.",
101+
comment: "Ineligible reason: feature flag disabled")
102+
case .selfDeallocated:
103+
return Localization.defaultReason
104+
}
105+
}
106+
}
107+
108+
private extension POSIneligibleView {
109+
enum Localization {
110+
static let refreshEligibility = NSLocalizedString(
111+
"pos.ineligible.refresh.button.title",
112+
value: "Check Eligibility Again",
113+
comment: "Button title to refresh POS eligibility check"
114+
)
115+
116+
/// Default message shown when POS eligibility reason is not available.
117+
static let defaultReason = NSLocalizedString(
118+
"pos.ineligible.default.reason",
119+
value: "Your store is not eligible for POS at this time.",
120+
comment: "Default message shown when POS eligibility reason is not available"
121+
)
122+
}
123+
}
124+
125+
#Preview {
126+
POSIneligibleView(
127+
reason: .unsupportedCurrency,
128+
onRefresh: {}
129+
)
130+
}

WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ final class POSTabCoordinator {
2828
private let storageManager: StorageManagerType
2929
private let currencySettings: CurrencySettings
3030
private let pushNotesManager: PushNotesManager
31+
private let eligibilityChecker: POSEntryPointEligibilityCheckerProtocol
3132

3233
private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = {
3334
PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials)
@@ -63,7 +64,8 @@ final class POSTabCoordinator {
6364
storesManager: StoresManager = ServiceLocator.stores,
6465
storageManager: StorageManagerType = ServiceLocator.storageManager,
6566
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
66-
pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager) {
67+
pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager,
68+
eligibilityChecker: POSEntryPointEligibilityCheckerProtocol) {
6769
self.siteID = siteID
6870
self.storesManager = storesManager
6971
self.tabContainerController = tabContainerController
@@ -72,6 +74,7 @@ final class POSTabCoordinator {
7274
self.storageManager = storageManager
7375
self.currencySettings = currencySettings
7476
self.pushNotesManager = pushNotesManager
77+
self.eligibilityChecker = eligibilityChecker
7578

7679
tabContainerController.wrappedController = POSTabViewController()
7780
}
@@ -121,7 +124,8 @@ private extension POSTabCoordinator {
121124
itemProvider: PointOfSaleItemService(currencySettings: currencySettings),
122125
itemFetchStrategyFactory: posPopularItemFetchStrategyFactory
123126
),
124-
barcodeScanService: barcodeScanService
127+
barcodeScanService: barcodeScanService,
128+
posEligibilityChecker: eligibilityChecker
125129
)
126130
let hostingController = UIHostingController(rootView: posView)
127131
hostingController.modalPresentationStyle = .fullScreen

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ protocol POSEntryPointEligibilityCheckerProtocol {
4545
}
4646

4747
final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
48+
private var siteSettingsEligibility: POSEligibilityState?
49+
private var featureFlagEligibility: POSEligibilityState?
50+
private var siteSettingsTask: Task<[SiteSetting], Never>?
51+
4852
private let siteID: Int64
4953
private let userInterfaceIdiom: UIUserInterfaceIdiom
5054
private let siteSettings: SelectedSiteSettingsProtocol
@@ -144,6 +148,7 @@ private extension POSTabEligibilityChecker {
144148
async let siteSettingsEligibility = checkSiteSettingsEligibility()
145149
async let featureFlagEligibility = checkRemoteFeatureEligibility()
146150

151+
self.siteSettingsEligibility = await siteSettingsEligibility
147152
switch await siteSettingsEligibility {
148153
case .eligible:
149154
break
@@ -155,6 +160,7 @@ private extension POSTabEligibilityChecker {
155160
}
156161
}
157162

163+
self.featureFlagEligibility = await featureFlagEligibility
158164
switch await featureFlagEligibility {
159165
case .eligible:
160166
return true
@@ -215,6 +221,10 @@ private extension POSTabEligibilityChecker {
215221

216222
private extension POSTabEligibilityChecker {
217223
func checkSiteSettingsEligibility() async -> POSEligibilityState {
224+
if let siteSettingsEligibility {
225+
return siteSettingsEligibility
226+
}
227+
218228
// Waits for the first site settings that matches the given site ID.
219229
let siteSettings = await waitForSiteSettingsRefresh()
220230
guard siteSettings.isNotEmpty else {
@@ -229,14 +239,28 @@ private extension POSTabEligibilityChecker {
229239
}
230240

231241
func waitForSiteSettingsRefresh() async -> [SiteSetting] {
232-
for await siteSettings in siteSettings.settingsStream {
233-
guard siteSettings.siteID == siteID, siteSettings.settings.isNotEmpty, siteSettings.source != .initialLoad else {
234-
continue
242+
// Uses a shared task so that multiple calls can await the same result since site settings can be emitted only once.
243+
if let existingTask = siteSettingsTask {
244+
return await existingTask.value
245+
}
246+
247+
let task = Task<[SiteSetting], Never> { [weak self] in
248+
guard let self else { return [] }
249+
250+
for await siteSettings in siteSettings.settingsStream {
251+
guard siteSettings.siteID == self.siteID, siteSettings.settings.isNotEmpty, siteSettings.source != .initialLoad else {
252+
continue
253+
}
254+
siteSettingsTask = nil
255+
return siteSettings.settings
235256
}
236-
return siteSettings.settings
257+
// If we get here, the stream completed without yielding any values for our site ID which is unexpected.
258+
siteSettingsTask = nil
259+
return []
237260
}
238-
// If we get here, the stream completed without yielding any values for our site ID which is unexpected.
239-
return []
261+
262+
siteSettingsTask = task
263+
return await task.value
240264
}
241265

242266
func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> POSEligibilityState {
@@ -263,9 +287,13 @@ private extension POSTabEligibilityChecker {
263287
private extension POSTabEligibilityChecker {
264288
@MainActor
265289
func checkRemoteFeatureEligibility() async -> POSEligibilityState {
290+
if let featureFlagEligibility {
291+
return featureFlagEligibility
292+
}
293+
266294
// Only whitelisted accounts in WPCOM have the Point of Sale remote feature flag enabled. These can be found at D159901-code
267295
// If the account is whitelisted, then the remote value takes preference over the local feature flag configuration
268-
await withCheckedContinuation { [weak self] continuation in
296+
return await withCheckedContinuation { [weak self] continuation in
269297
guard let self else {
270298
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
271299
}

WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ struct HubMenu: View {
6464
popularPurchasableItemsController: PointOfSaleItemsController(
6565
itemProvider: PointOfSaleItemService(currencySettings: ServiceLocator.currencySettings),
6666
itemFetchStrategyFactory: viewModel.posPopularItemFetchStrategyFactory),
67-
barcodeScanService: viewModel.barcodeScanService)
67+
barcodeScanService: viewModel.barcodeScanService,
68+
posEligibilityChecker: POSTabEligibilityChecker(siteID: viewModel.siteID))
6869
} else {
6970
// TODO: When we have a singleton for the card payment service, this should not be required.
7071
Text("Error creating card payment service")

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,8 @@ private extension MainTabBarController {
746746
siteID: siteID,
747747
tabContainerController: posContainerController,
748748
viewControllerToPresent: self,
749-
storesManager: stores
749+
storesManager: stores,
750+
eligibilityChecker: posEligibilityChecker
750751
)
751752

752753
// Configure hub menu tab coordinator once per logged in session potentially with multiple sites.

0 commit comments

Comments
 (0)