Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ protocol PointOfSaleSettingsControllerProtocol {
var connectedCardReader: CardPresentPaymentCardReader? { get }
var storeViewModel: POSSettingsStoreViewModel { get }
var localCatalogViewModel: POSSettingsLocalCatalogViewModel? { get }
var isLocalCatalogEligible: Bool { get }
}

@Observable final class PointOfSaleSettingsController: PointOfSaleSettingsControllerProtocol {
Expand All @@ -23,6 +24,9 @@ protocol PointOfSaleSettingsControllerProtocol {

let storeViewModel: POSSettingsStoreViewModel
let localCatalogViewModel: POSSettingsLocalCatalogViewModel?
private let localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol?

var isLocalCatalogEligible: Bool = false

init(siteID: Int64,
settingsService: PointOfSaleSettingsServiceProtocol,
Expand All @@ -31,12 +35,15 @@ protocol PointOfSaleSettingsControllerProtocol {
defaultSiteName: String?,
siteSettings: [SiteSetting],
grdbManager: GRDBManagerProtocol?,
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?) {
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?,
localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol?) {
self.storeViewModel = POSSettingsStoreViewModel(siteID: siteID,
settingsService: settingsService,
pluginsService: pluginsService,
defaultSiteName: defaultSiteName,
siteSettings: siteSettings)
self.localCatalogEligibilityService = localCatalogEligibilityService

if let catalogSyncCoordinator, let grdbManager {
self.localCatalogViewModel = POSSettingsLocalCatalogViewModel(
siteID: siteID,
Expand All @@ -48,6 +55,8 @@ protocol PointOfSaleSettingsControllerProtocol {
}

observeCardReader(from: cardPresentPaymentService)

checkLocalCatalogEligibility()
}

private func observeCardReader(from service: CardPresentPaymentFacade) {
Expand All @@ -64,6 +73,10 @@ protocol PointOfSaleSettingsControllerProtocol {
connectedCardReader = cardReader
})
}

private func checkLocalCatalogEligibility() {
isLocalCatalogEligible = localCatalogEligibilityService?.eligibilityState == .eligible
Copy link
Contributor

Choose a reason for hiding this comment

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

This kind of implementation (deliberately?) assumes that eligibilityState cannot change while the POS is in use. Which may be what we want, so I just want to make sure it's intended?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it's intended... but we build that in to the service, so we could just use the service directly and still get the benefit.

}
}

#if DEBUG
Expand All @@ -80,6 +93,10 @@ final class PointOfSaleSettingsPreviewController: PointOfSaleSettingsControllerP
siteSettings: [])

var localCatalogViewModel: POSSettingsLocalCatalogViewModel?

var isLocalCatalogEligible: Bool {
localCatalogViewModel != nil
}
}

final class MockPointOfSaleSettingsService: PointOfSaleSettingsServiceProtocol {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,10 @@ public struct PointOfSaleEntryPointView: View {
services: POSDependencyProviding) {
self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange

// Use observable controller with GRDB if available and feature flag is enabled, otherwise fall back to standard controller
// Note: We check feature flag here for eligibility. Once eligibility checking is
// refactored to be more centralized, this check can be simplified.
let isGRDBEnabled = services.featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1)
if let grdbManager = grdbManager, catalogSyncCoordinator != nil, isGRDBEnabled {
// Use observable controller with GRDB if local catalog is eligible,
// otherwise fall back to standard controller.
let isLocalCatalogEligible = services.localCatalogEligibility.localCatalogEligibilityService?.eligibilityState == .eligible
if isLocalCatalogEligible, let grdbManager = grdbManager {
self.itemsController = PointOfSaleObservableItemsController(
siteID: siteID,
grdbManager: grdbManager,
Expand Down Expand Up @@ -113,7 +112,8 @@ public struct PointOfSaleEntryPointView: View {
defaultSiteName: defaultSiteName,
siteSettings: siteSettings,
grdbManager: grdbManager,
catalogSyncCoordinator: catalogSyncCoordinator)
catalogSyncCoordinator: catalogSyncCoordinator,
localCatalogEligibilityService: services.localCatalogEligibility.localCatalogEligibilityService)
self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker
self.searchHistoryService = searchHistoryService
self.popularPurchasableItemsController = PointOfSaleItemsController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import SwiftUI
struct PointOfSaleSettingsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.posAnalytics) private var analytics
@Environment(\.posFeatureFlags) private var featureFlags
@State private var selection: SidebarNavigation? = .store

let settingsController: PointOfSaleSettingsControllerProtocol
Expand Down Expand Up @@ -55,8 +54,7 @@ extension PointOfSaleSettingsView {
}
)

// TODO: WOOMOB-1287 - integrate with local catalog feature eligibility
if featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) && settingsController.localCatalogViewModel != nil {
if settingsController.isLocalCatalogEligible {
PointOfSaleSettingsCard(
item: .localCatalog,
isSelected: selection == .localCatalog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public protocol POSExternalViewProviding {
func createWCWebView(adminUrl: URL, completion: @escaping () -> Void) -> AnyView
}

/// Protocol that provides local catalog eligibility service for POS
public protocol POSLocalCatalogEligibilityProviding {
var localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol? { get }
}

/// Main protocol that combines all POS dependency providers
/// This enables dependency injection for POS code while maintaining clean separation from ServiceLocator
public protocol POSDependencyProviding {
Expand All @@ -73,4 +78,5 @@ public protocol POSDependencyProviding {
var connectivity: POSConnectivityProviding { get }
var externalNavigation: POSExternalNavigationProviding { get }
var externalViews: POSExternalViewProviding { get }
var localCatalogEligibility: POSLocalCatalogEligibilityProviding { get }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Eligibility state for local catalog feature
/// Provides diagnostic information for UI display and decision-making
public enum POSLocalCatalogEligibilityState: Equatable {
/// Local catalog is eligible for use
case eligible

/// Local catalog is not eligible
case ineligible(reason: POSLocalCatalogIneligibleReason)
}

/// Reasons why local catalog is ineligible
public enum POSLocalCatalogIneligibleReason: Equatable {
case posTabNotEligible
case featureFlagDisabled
case catalogSizeTooLarge(totalCount: Int, limit: Int)
case catalogSizeCheckFailed(underlyingError: String)
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 This is good and tidy, we will be able to include all the ineligibility cases here.

}

/// Service that provides eligibility information for local catalog feature
///
/// Other services can query this for eligibility state and reasons:
/// - Sync coordinator can check if catalog is eligible
/// - Settings UI can display eligibility status and reasons
/// - Analytics can track why stores are ineligible
///
/// NOTE: This service checks catalog-related eligibility (size limits) and feature flag state.
/// The service performs an initial eligibility check during initialization.
public protocol POSLocalCatalogEligibilityServiceProtocol {
/// Current eligibility state (synchronously accessible on main thread)
var eligibilityState: POSLocalCatalogEligibilityState { get }

/// Force refresh eligibility (bypasses cache and updates eligibilityState)
/// - Returns: Fresh eligibility state with reason if ineligible
@discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState
}
5 changes: 5 additions & 0 deletions Modules/Sources/PointOfSale/Utils/POSEnvironmentKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,8 @@ struct EmptyPOSExternalView: POSExternalViewProviding {
}
init() {}
}

struct EmptyPOSLocalCatalogEligibility: POSLocalCatalogEligibilityProviding {
var localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol? = nil
init() {}
}
1 change: 1 addition & 0 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ final class POSPreviewServices: POSDependencyProviding {
var connectivity: POSConnectivityProviding = EmptyPOSConnectivityProvider()
var externalNavigation: POSExternalNavigationProviding = EmptyPOSExternalNavigation()
var externalViews: POSExternalViewProviding = EmptyPOSExternalView()
var localCatalogEligibility: POSLocalCatalogEligibilityProviding = EmptyPOSLocalCatalogEligibility()
}

// MARK: - Preview Catalog Services
Expand Down
10 changes: 10 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Networking
import Combine

/// Protocol for checking the size of a remote POS catalog
public protocol POSCatalogSizeCheckerProtocol {
Expand All @@ -18,6 +19,15 @@ public struct POSCatalogSizeChecker: POSCatalogSizeCheckerProtocol {
self.syncRemote = syncRemote
}

public init(credentials: Credentials?,
selectedSite: AnyPublisher<JetpackSite?, Never>,
appPasswordSupportState: AnyPublisher<Bool, Never>) {
let syncRemote = POSCatalogSyncRemote(network: AlamofireNetwork(credentials: credentials,
selectedSite: selectedSite,
appPasswordSupportState: appPasswordSupportState))
self.init(syncRemote: syncRemote)
}

public func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize {
// Make concurrent requests to get both counts
async let productCount = syncRemote.getProductCount(siteID: siteID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
@testable import Yosemite
import Storage

@MainActor
struct PointOfSaleSettingsControllerTests {
private let mockSettingsService = MockPointOfSaleSettingsService()
private let mockCardPresentPaymentService = MockCardPresentPaymentService()
Expand All @@ -19,7 +20,8 @@ struct PointOfSaleSettingsControllerTests {
defaultSiteName: "Test Store",
siteSettings: [],
grdbManager: nil,
catalogSyncCoordinator: nil)
catalogSyncCoordinator: nil,
localCatalogEligibilityService: MockPOSLocalCatalogEligibilityService())

// When
let cardReader = sut.connectedCardReader
Expand All @@ -38,7 +40,8 @@ struct PointOfSaleSettingsControllerTests {
defaultSiteName: "Test Store",
siteSettings: [],
grdbManager: nil,
catalogSyncCoordinator: nil)
catalogSyncCoordinator: nil,
localCatalogEligibilityService: MockPOSLocalCatalogEligibilityService())

// Initially nil
#expect(sut.connectedCardReader == nil)
Expand Down Expand Up @@ -80,4 +83,5 @@ final class MockPointOfSaleSettingsController: PointOfSaleSettingsControllerProt
defaultSiteName: "Sample Store",
siteSettings: [])
var localCatalogViewModel: POSSettingsLocalCatalogViewModel?
var isLocalCatalogEligible = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation
@testable import PointOfSale

/// Mock implementation of POSLocalCatalogEligibilityServiceProtocol for testing
@MainActor
public final class MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol {
public var eligibilityState: POSLocalCatalogEligibilityState
public var refreshCallCount = 0

public init(eligibilityState: POSLocalCatalogEligibilityState = .eligible) {
self.eligibilityState = eligibilityState
}

public func refreshEligibilityState() async -> POSLocalCatalogEligibilityState {
refreshCallCount += 1
return eligibilityState
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import Foundation
@testable import Yosemite

final class MockPOSCatalogSizeChecker: POSCatalogSizeCheckerProtocol {
// MARK: - checkCatalogSize tracking
private(set) var checkCatalogSizeCallCount = 0
private(set) var lastCheckedSiteID: Int64?
var checkCatalogSizeResult: Result<POSCatalogSize, Error> = .success(POSCatalogSize(productCount: 100, variationCount: 50)) // 150 total - well under limit
var sizeToReturn: Result<POSCatalogSize, Error>
var checkCatalogSizeCallCount = 0
var lastCheckedSiteID: Int64?

init(sizeToReturn: Result<POSCatalogSize, Error> = .success(POSCatalogSize(productCount: 100, variationCount: 50))) {
self.sizeToReturn = sizeToReturn
}

func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize {
checkCatalogSizeCallCount += 1
lastCheckedSiteID = siteID

switch checkCatalogSizeResult {
switch sizeToReturn {
case .success(let size):
return size
case .failure(let error):
Expand Down
Loading