Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
28 changes: 28 additions & 0 deletions WooCommerce/Classes/POS/Controllers/POSEntryPointController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI
import protocol Experiments.FeatureFlagService

@available(iOS 17.0, *)
@Observable final class POSEntryPointController {
private(set) var eligibilityState: POSEligibilityState?
private let posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol
private let featureFlagService: FeatureFlagService

init(eligibilityChecker: POSEntryPointEligibilityCheckerProtocol,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
self.posEligibilityChecker = eligibilityChecker
self.featureFlagService = featureFlagService

guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) else {
self.eligibilityState = .eligible
return
}
Task { @MainActor in
eligibilityState = await posEligibilityChecker.checkEligibility()
}
}

@MainActor
func refreshEligibility() async throws {
// TODO: WOOMOB-720 - refresh eligibility
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol
struct PointOfSaleEntryPointView: View {
@State private var posModel: PointOfSaleAggregateModel?
@StateObject private var posModalManager = POSModalManager()
@State private var posEntryPointController: POSEntryPointController
@Environment(\.horizontalSizeClass) private var horizontalSizeClass

private let onPointOfSaleModeActiveStateChange: ((Bool) -> Void)
Expand All @@ -30,7 +31,8 @@ struct PointOfSaleEntryPointView: View {
collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking,
searchHistoryService: POSSearchHistoryProviding,
popularPurchasableItemsController: PointOfSaleItemsControllerProtocol,
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol) {
barcodeScanService: PointOfSaleBarcodeScanServiceProtocol,
posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol) {
self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange

self.itemsController = itemsController
Expand All @@ -43,15 +45,25 @@ struct PointOfSaleEntryPointView: View {
self.searchHistoryService = searchHistoryService
self.popularPurchasableItemsController = popularPurchasableItemsController
self.barcodeScanService = barcodeScanService
self.posEntryPointController = POSEntryPointController(eligibilityChecker: posEligibilityChecker)
}

var body: some View {
Group {
if let posModel = posModel {
PointOfSaleDashboardView()
.environment(posModel)
} else {
switch posEntryPointController.eligibilityState {
case .none:
PointOfSaleLoadingView()
case .eligible:
if let posModel = posModel {
PointOfSaleDashboardView()
.environment(posModel)
} else {
PointOfSaleLoadingView()
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 this visible shift between one loading state and another. For some reason, even the background color changes.

Simulator.Screen.Recording.-.iPad.Air.13-inch.M2.-.2025-06-30.at.10.56.56.mov

Copy link
Contributor Author

@jaclync jaclync Jun 30, 2025

Choose a reason for hiding this comment

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

Thanks for catching this! I was testing in dark mode most of the time and didn't notice it. I can reproduce this especially easily by adding a await Task.sleep(2 * NSEC_PER_SEC) delay to POSTabEligibilityChecker.checkEligibility.

It turns out the background color of the loading state changes because PointOfSaleLoadingView does not have a background color set. Therefore, the background color depends on the parent view's background color. PointOfSaleLoadingView is displayed in PointOfSaleEntryPointView and PointOfSaleDashboardView. Before this PR, the loading state is mostly displayed to the user when embedded in PointOfSaleDashboardView with a background color set while loading the products, the use case in PointOfSaleEntryPointView is just for the POS aggregate model's initialization in task which is almost instant. In this PR, the loading state is being shown from PointOfSaleEntryPointView when checking POS eligibility async but PointOfSaleEntryPointView doesn't have a background color set. In 9feda59, I set the same background color as PointOfSaleDashboardView to PointOfSaleLoadingView so that the loading state has a consistent background color no matter where it is embedded.

before:

Simulator.Screen.Recording.-.iPad.Air.13-inch.M3.-.2025-06-30.at.13.10.01.mp4

after:

Simulator.Screen.Recording.-.iPad.Air.13-inch.M3.-.2025-06-30.at.12.43.46.mp4

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for addressing this!

I wonder if SwiftUI could keep the same animation running while we switch from entry point loading -> products loading. However, let's not address it now; we have this issue with other animations as well. Probably making IndefiniteCircularProgressViewStyle reliant on a current time when showing progress or something like that could make the transitions smooth.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wonder if SwiftUI could keep the same animation running while we switch from entry point loading -> products loading. However, let's not address it now; we have this issue with other animations as well. Probably making IndefiniteCircularProgressViewStyle reliant on a current time when showing progress or something like that could make the transitions smooth.

Yea, certainly worth looking into streamlining the spinner animation for the loading view from 2 different places. I'm also still confirming the loading UI in p1751379425731039-slack-C0354HSNUJH, though I think it's pretty likely we will reuse the spinner animation. Created an issue WOOMOB-741.

}
case let .ineligible(reason):
POSIneligibleView(reason: reason, onRefresh: {
try await posEntryPointController.refreshEligibility()
})
}
}
.task {
Expand Down Expand Up @@ -96,7 +108,8 @@ struct PointOfSaleEntryPointView: View {
collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalytics(),
searchHistoryService: PointOfSalePreviewHistoryService(),
popularPurchasableItemsController: PointOfSalePreviewItemsController(),
barcodeScanService: PointOfSalePreviewBarcodeScanService())
barcodeScanService: PointOfSalePreviewBarcodeScanService(),
posEligibilityChecker: POSTabEligibilityChecker(siteID: 0))
}

#endif
130 changes: 130 additions & 0 deletions WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import SwiftUI

/// A view that displays when the Point of Sale (POS) feature is not available for the current store.
/// Shows the specific reason why POS is ineligible and provides a button to re-check eligibility.
struct POSIneligibleView: View {
let reason: POSIneligibleReason
let onRefresh: () async throws -> Void
@Environment(\.dismiss) private var dismiss
@State private var isLoading: Bool = false

var body: some View {
VStack(spacing: POSSpacing.large) {
HStack {
Spacer()
Button {
dismiss()
} label: {
Text(Image(systemName: "xmark"))
.font(POSFontStyle.posButtonSymbolLarge.font())
}
.foregroundColor(Color.posOnSurfaceVariantLowest)
}

Spacer()

VStack(spacing: POSSpacing.medium) {
Image(PointOfSaleAssets.exclamationMark.imageName)
.resizable()
.frame(width: POSErrorAndAlertIconSize.large.dimension,
height: POSErrorAndAlertIconSize.large.dimension)

Text(reasonText)
.font(POSFontStyle.posHeadingBold.font())
.multilineTextAlignment(.center)
.foregroundColor(Color.posOnSurface)

Button {
Task { @MainActor in
do {
isLoading = true
try await onRefresh()
isLoading = false
} catch {
// TODO-jc: handle error if needed, e.g., show an error message
print("Error refreshing eligibility: \(error)")
isLoading = false
}
}
} label: {
Text(Localization.refreshEligibility)
}
.buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isLoading))
}

Spacer()
}
.padding(POSPadding.large)
}

private var reasonText: String {
switch reason {
case .notTablet:
return NSLocalizedString("pos.ineligible.reason.notTablet",
value: "POS is only available on iPad.",
comment: "Ineligible reason: not a tablet")
case .unsupportedIOSVersion:
return NSLocalizedString("pos.ineligible.reason.unsupportedIOSVersion",
value: "POS requires a newer version of iOS 17 and above.",
comment: "Ineligible reason: iOS version too low")
case .unsupportedWooCommerceVersion:
return NSLocalizedString("pos.ineligible.reason.unsupportedWooCommerceVersion",
value: "Please update WooCommerce plugin to use POS.",
comment: "Ineligible reason: WooCommerce version too low")
case .wooCommercePluginNotFound:
return NSLocalizedString("pos.ineligible.reason.wooCommercePluginNotFound",
value: "WooCommerce plugin not found.",
comment: "Ineligible reason: plugin missing")
case .featureSwitchDisabled:
return NSLocalizedString("pos.ineligible.reason.featureSwitchDisabled",
value: "POS feature is not enabled for your store.",
comment: "Ineligible reason: feature switch off")
case .featureSwitchSyncFailure:
return NSLocalizedString("pos.ineligible.reason.featureSwitchSyncFailure",
value: "Could not verify POS feature status.",
comment: "Ineligible reason: feature switch sync failed")
case .unsupportedCountry:
return NSLocalizedString("pos.ineligible.reason.unsupportedCountry",
value: "POS is not available in your country.",
comment: "Ineligible reason: country not supported")
case .unsupportedCurrency:
return NSLocalizedString("pos.ineligible.reason.unsupportedCurrency",
value: "POS is not available for your store's currency.",
comment: "Ineligible reason: currency not supported")
case .siteSettingsNotAvailable:
return NSLocalizedString("pos.ineligible.reason.siteSettingsNotAvailable",
value: "Unable to load store settings for POS.",
comment: "Ineligible reason: site settings unavailable")
case .featureFlagDisabled:
return NSLocalizedString("pos.ineligible.reason.featureFlagDisabled",
value: "POS feature is currently disabled.",
comment: "Ineligible reason: feature flag disabled")
case .selfDeallocated:
return Localization.defaultReason
}
}
}

private extension POSIneligibleView {
enum Localization {
static let refreshEligibility = NSLocalizedString(
"pos.ineligible.refresh.button.title",
value: "Check Eligibility Again",
comment: "Button title to refresh POS eligibility check"
)

/// Default message shown when POS eligibility reason is not available.
static let defaultReason = NSLocalizedString(
"pos.ineligible.default.reason",
value: "Your store is not eligible for POS at this time.",
comment: "Default message shown when POS eligibility reason is not available"
)
}
}

#Preview {
POSIneligibleView(
reason: .unsupportedCurrency,
onRefresh: {}
)
}
8 changes: 6 additions & 2 deletions WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class POSTabCoordinator {
private let storageManager: StorageManagerType
private let currencySettings: CurrencySettings
private let pushNotesManager: PushNotesManager
private let eligibilityChecker: POSEntryPointEligibilityCheckerProtocol

private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = {
PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials)
Expand Down Expand Up @@ -63,7 +64,8 @@ final class POSTabCoordinator {
storesManager: StoresManager = ServiceLocator.stores,
storageManager: StorageManagerType = ServiceLocator.storageManager,
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager) {
pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager,
eligibilityChecker: POSEntryPointEligibilityCheckerProtocol) {
self.siteID = siteID
self.storesManager = storesManager
self.tabContainerController = tabContainerController
Expand All @@ -72,6 +74,7 @@ final class POSTabCoordinator {
self.storageManager = storageManager
self.currencySettings = currencySettings
self.pushNotesManager = pushNotesManager
self.eligibilityChecker = eligibilityChecker

tabContainerController.wrappedController = POSTabViewController()
}
Expand Down Expand Up @@ -121,7 +124,8 @@ private extension POSTabCoordinator {
itemProvider: PointOfSaleItemService(currencySettings: currencySettings),
itemFetchStrategyFactory: posPopularItemFetchStrategyFactory
),
barcodeScanService: barcodeScanService
barcodeScanService: barcodeScanService,
posEligibilityChecker: eligibilityChecker
)
let hostingController = UIHostingController(rootView: posView)
hostingController.modalPresentationStyle = .fullScreen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ protocol POSEntryPointEligibilityCheckerProtocol {
}

final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
private var siteSettingsEligibility: POSEligibilityState?
private var featureFlagEligibility: POSEligibilityState?

private let siteID: Int64
private let userInterfaceIdiom: UIUserInterfaceIdiom
private let siteSettings: SelectedSiteSettingsProtocol
Expand Down Expand Up @@ -144,6 +147,7 @@ private extension POSTabEligibilityChecker {
async let siteSettingsEligibility = checkSiteSettingsEligibility()
async let featureFlagEligibility = checkRemoteFeatureEligibility()

self.siteSettingsEligibility = await siteSettingsEligibility
switch await siteSettingsEligibility {
case .eligible:
break
Expand All @@ -155,6 +159,7 @@ private extension POSTabEligibilityChecker {
}
}

self.featureFlagEligibility = await featureFlagEligibility
switch await featureFlagEligibility {
case .eligible:
return true
Expand Down Expand Up @@ -215,6 +220,10 @@ private extension POSTabEligibilityChecker {

private extension POSTabEligibilityChecker {
func checkSiteSettingsEligibility() async -> POSEligibilityState {
if let siteSettingsEligibility {
Copy link
Contributor

Choose a reason for hiding this comment

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

Until the app is killed and relaunched, will we keep the same eligibility state?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

POSTabEligibilityChecker's lifetime is per logged-in site session, so it stays the same until switching stores / logout / app relaunched.

In a future task WOOMOB-720, the refresh eligibility logic will refresh site settings with a remote sync when the ineligible reason is related to the site settings. The app also assumes the same store country/currency throughout the site session as the site settings are refreshed only during site initialization. We could also sync the site settings remotely every time tapping on the POS tab, but I felt like that's optimizing the accuracy for a small ratio of edge cases (site settings changed within the site session) with the tradeoff of an additional API request.

Copy link
Contributor

@staskus staskus Jul 1, 2025

Choose a reason for hiding this comment

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

Got it, thanks for the explanation!

return siteSettingsEligibility
}

// Waits for the first site settings that matches the given site ID.
let siteSettings = await waitForSiteSettingsRefresh()
guard siteSettings.isNotEmpty else {
Expand Down Expand Up @@ -263,9 +272,13 @@ private extension POSTabEligibilityChecker {
private extension POSTabEligibilityChecker {
@MainActor
func checkRemoteFeatureEligibility() async -> POSEligibilityState {
if let featureFlagEligibility {
return featureFlagEligibility
}

// 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
return await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
Expand Down
3 changes: 2 additions & 1 deletion WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ struct HubMenu: View {
popularPurchasableItemsController: PointOfSaleItemsController(
itemProvider: PointOfSaleItemService(currencySettings: ServiceLocator.currencySettings),
itemFetchStrategyFactory: viewModel.posPopularItemFetchStrategyFactory),
barcodeScanService: viewModel.barcodeScanService)
barcodeScanService: viewModel.barcodeScanService,
posEligibilityChecker: POSTabEligibilityChecker(siteID: viewModel.siteID))
} else {
// TODO: When we have a singleton for the card payment service, this should not be required.
Text("Error creating card payment service")
Expand Down
3 changes: 2 additions & 1 deletion WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,8 @@ private extension MainTabBarController {
siteID: siteID,
tabContainerController: posContainerController,
viewControllerToPresent: self,
storesManager: stores
storesManager: stores,
eligibilityChecker: posEligibilityChecker
)

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