Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,7 @@ protocol PointOfSaleSettingsControllerProtocol {

let storeViewModel: POSSettingsStoreViewModel
let localCatalogViewModel: POSSettingsLocalCatalogViewModel?
let isLocalCatalogEligible: Bool

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

if let catalogSyncCoordinator, let grdbManager {
self.localCatalogViewModel = POSSettingsLocalCatalogViewModel(
siteID: siteID,
Expand Down Expand Up @@ -80,6 +85,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 @@ -63,14 +63,13 @@ public struct PointOfSaleEntryPointView: View {
siteSettings: [SiteSetting],
grdbManager: GRDBManagerProtocol?,
catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?,
isLocalCatalogEligible: Bool,
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.
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,
isLocalCatalogEligible: isLocalCatalogEligible)
self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker
self.searchHistoryService = searchHistoryService
self.popularPurchasableItemsController = PointOfSaleItemsController(
Expand Down Expand Up @@ -203,6 +203,7 @@ public struct PointOfSaleEntryPointView: View {
siteSettings: [],
grdbManager: nil,
catalogSyncCoordinator: nil,
isLocalCatalogEligible: false,
services: POSPreviewServices()
)
}
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
@@ -0,0 +1,41 @@
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 posTabNotVisible
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 }

/// Update the POS tab visibility state and refresh eligibility
/// - Parameter isPOSTabVisible: Whether the POS tab is visible
func updateVisibility(isPOSTabVisible: Bool) async

/// Force refresh eligibility (bypasses cache and updates eligibilityState)
/// - Returns: Fresh eligibility state with reason if ineligible
@discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState
}
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,
isLocalCatalogEligible: true)

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

// 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,20 @@
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
}

public func updateVisibility(isPOSTabVisible: Bool) async { }
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ struct POSCatalogSyncCoordinatorTests {

@Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit() async throws {
// Given - catalog size is above the 1000 item limit
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)

// When
Expand All @@ -164,7 +164,7 @@ struct POSCatalogSyncCoordinatorTests {

@Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_at_limit() async throws {
// Given - catalog size is exactly at the 1000 item limit
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)

// When
Expand All @@ -178,7 +178,7 @@ struct POSCatalogSyncCoordinatorTests {

@Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_under_limit() async throws {
// Given - catalog size is below the 1000 item limit
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)

// When
Expand All @@ -193,7 +193,7 @@ struct POSCatalogSyncCoordinatorTests {
@Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_check_fails() async throws {
// Given - catalog size check throws an error
let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"])
mockCatalogSizeChecker.checkCatalogSizeResult = .failure(sizeCheckError)
mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)

// When
Expand All @@ -207,7 +207,7 @@ struct POSCatalogSyncCoordinatorTests {

@Test func performFullSyncIfApplicable_respects_time_only_when_catalog_size_is_acceptable() async throws {
// Given - catalog size is acceptable but sync is recent
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo)

Expand Down Expand Up @@ -504,7 +504,7 @@ struct POSCatalogSyncCoordinatorTests {
@Test(arguments: [.zero, 60 * 60])
func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit(maxAge: TimeInterval) async throws {
// Given - catalog size is above the 1000 item limit
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total
let fullSyncDate = Date().addingTimeInterval(-3600)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)

Expand All @@ -520,7 +520,7 @@ struct POSCatalogSyncCoordinatorTests {
@Test(arguments: [.zero, 60 * 60])
func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_at_limit(maxAge: TimeInterval) async throws {
// Given - catalog size is exactly at the 1000 item limit
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total
let fullSyncDate = Date().addingTimeInterval(-3600)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)

Expand All @@ -536,7 +536,7 @@ struct POSCatalogSyncCoordinatorTests {
@Test(arguments: [.zero, 60 * 60])
func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_under_limit(maxAge: TimeInterval) async throws {
// Given - catalog size is below the 1000 item limit
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total
let fullSyncDate = Date().addingTimeInterval(-3600)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)

Expand All @@ -553,7 +553,7 @@ struct POSCatalogSyncCoordinatorTests {
func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_check_fails(maxAge: TimeInterval) async throws {
// Given - catalog size check throws an error
let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"])
mockCatalogSizeChecker.checkCatalogSizeResult = .failure(sizeCheckError)
mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError)
let fullSyncDate = Date().addingTimeInterval(-3600)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)

Expand All @@ -568,7 +568,7 @@ struct POSCatalogSyncCoordinatorTests {

@Test func performIncrementalSyncIfApplicable_checks_size_before_age_check() async throws {
// Given - catalog is over limit but would otherwise sync due to age
mockCatalogSizeChecker.checkCatalogSizeResult = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total
let maxAge: TimeInterval = 2
let staleIncrementalSyncDate = Date().addingTimeInterval(-(maxAge + 1)) // Older than max age
let fullSyncDate = Date().addingTimeInterval(-3600)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import protocol PointOfSale.POSExternalNavigationProviding
import protocol PointOfSale.POSExternalViewProviding

final class POSServiceLocatorAdaptor: POSDependencyProviding {
init() {
}

var analytics: POSAnalyticsProviding {
POSAnalyticsAdaptor()
}
Expand Down
Loading