diff --git a/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift b/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift index 727014568cd..a4bb7e287b3 100644 --- a/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift +++ b/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift @@ -43,4 +43,18 @@ struct POSItemFetchAnalytics: POSItemFetchAnalyticsTracking { ) ) } + + /// Tracks when a local search results fetch completes + /// - Parameters: + /// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds + /// - totalItems: The total number of items found in the search + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + analytics.track( + event: .PointOfSale.pointOfSaleSearchResultsFetched( + itemType: itemType, + resultsCount: totalItems, + millisecondsSinceRequestSent: millisecondsSinceRequestSent + ) + ) + } } diff --git a/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift b/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift index b1f6cec3cf3..17eedb6c15d 100644 --- a/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift +++ b/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift @@ -305,6 +305,17 @@ extension WooAnalyticsEvent { ]) } + static func pointOfSaleSearchResultsFetched(itemType: POSItemType, + resultsCount: Int, + millisecondsSinceRequestSent: Int) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleSearchResultsFetched, + properties: [ + Key.sourceView: SourceView(itemType: itemType).rawValue, + Key.resultsCount: "\(resultsCount)", + Key.millisecondsSinceRequestSent: "\(millisecondsSinceRequestSent)" + ]) + } + static func pointOfSaleItemsFetched(itemType: POSItemType, totalItems: Int) -> WooAnalyticsEvent { WooAnalyticsEvent(statName: .pointOfSaleItemsFetched, diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift index 388878c3427..024f1e7847c 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift @@ -7,6 +7,7 @@ import protocol Yosemite.PointOfSaleCouponServiceProtocol import struct Yosemite.PointOfSaleCouponFetchStrategyFactory import protocol Yosemite.PointOfSaleCouponFetchStrategy import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControllerProtocol { /// Enables coupons in store settings @@ -57,6 +58,18 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll setSearchingState() } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + let searchStrategy = fetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .coupon, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor func loadNextItems(base: ItemListBaseItem) async { guard paginationTracker.hasNextPage else { diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index b67da2bce67..b14221eb7c8 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -11,6 +11,7 @@ import struct Yosemite.POSVariableParentProduct import class Yosemite.Store import enum Yosemite.POSItemType import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleItemsControllerProtocol { /// @@ -27,6 +28,10 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController /// Searches for items func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async func clearSearchItems(baseItem: ItemListBaseItem) + /// The debouncing strategy from the current fetch strategy + var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search + var searchDebounceStrategy: SearchDebounceStrategy { get } } @@ -70,7 +75,6 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm, analytics: POSItemFetchAnalytics(itemType: .product, analytics: analyticsProvider)) - setSearchingState(base: baseItem) await loadFirstPage(base: baseItem) } @@ -78,6 +82,19 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController setSearchingState(base: baseItem) } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + // We create a temporary search strategy to get its debounce settings + let searchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .product, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor private func loadFirstPage(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift index a74053265dd..1869e7849de 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift @@ -2,6 +2,7 @@ import Foundation import enum Yosemite.POSItemType import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class POSProductSearchable: POSSearchable { private let itemListType: ItemListType @@ -24,6 +25,14 @@ final class POSProductSearchable: POSSearchable { itemListType.itemType.searchFieldLabel } + var currentDebounceStrategy: SearchDebounceStrategy { + itemsController.currentDebounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + itemsController.searchDebounceStrategy + } + func performSearch(term: String) async { await itemsController.searchItems(searchTerm: term, baseItem: .root) } diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 974b7d09868..396ca103e93 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -1,12 +1,17 @@ import SwiftUI import enum Yosemite.POSItemType import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy /// Protocol defining search capabilities for POS items protocol POSSearchable { var searchFieldPlaceholder: String { get } /// Recent search history for the current item type var searchHistory: [String] { get } + /// The debouncing strategy currently active based on the controller's current state + var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search (may differ from current strategy) + var searchDebounceStrategy: SearchDebounceStrategy { get } /// Called when a search should be performed /// - Parameter term: The search term to use @@ -54,40 +59,7 @@ struct POSSearchField: View { .textInputAutocapitalization(.never) .focused($isSearchFieldFocused) .onChange(of: searchTerm) { oldValue, newValue in - // The debouncing logic is a little tricky, because the loading state is held in the controller. - // Arguably, we should use view state `isSearching` for this, so the UI is independent of the request timing. - - // As the user types, we don't want to send every keystroke to the remote, so we debounce the requests. - // However, we don't want to debounce the first keystroke of a new search, so that the loading - // state shows immediately and the UI feels responsive. - - // So, if the last search was finished, we don't debounce the first character. If it didn't - // finish i.e. it is still ongoing, we debounce the next keystrokes by 300ms. In either case, - // the ongoing search is redundant now there's a new search term, so we cancel it. - let shouldDebounceNextSearchRequest = !didFinishSearch - searchTask?.cancel() - - searchTask = Task { - if shouldDebounceNextSearchRequest { - try? await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) - } else { - searchable.clearSearchResults() - } - - guard !Task.isCancelled else { return } - - guard newValue.isNotEmpty else { - didFinishSearch = true - return - } - - didFinishSearch = false - await searchable.performSearch(term: newValue) - - if !Task.isCancelled { - didFinishSearch = true - } - } + handleSearchTermChange(newValue) } } .onChange(of: keyboardObserver.isKeyboardVisible) { _, isVisible in @@ -100,6 +72,142 @@ struct POSSearchField: View { } } +// MARK: - Search Handling +private extension POSSearchField { + func handleSearchTermChange(_ newValue: String) { + searchTask?.cancel() + + let debounceStrategy = selectDebounceStrategy(for: newValue) + + searchTask = Task { + await executeSearchWithStrategy(debounceStrategy, searchTerm: newValue) + } + } + + func selectDebounceStrategy(for searchTerm: String) -> SearchDebounceStrategy { + // Use searchDebounceStrategy for non-empty search terms (actual searches), + // and currentDebounceStrategy for empty terms (returning to popular products). + searchTerm.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy + } + + func executeSearchWithStrategy(_ strategy: SearchDebounceStrategy, searchTerm: String) async { + switch strategy { + case .smart(let duration, let loadingDelayThreshold): + await executeSmartDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .simple(let duration, let loadingDelayThreshold): + await executeSimpleDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .immediate: + await executeImmediateSearch(searchTerm: searchTerm) + } + } + + func executeSmartDebouncedSearch(duration: UInt64, loadingDelayThreshold: UInt64?, searchTerm: String) async { + // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes + // The loading indicator behavior depends on whether there's a threshold: + // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker) + // - Without threshold: Show loading immediately (responsive feel) + + let isFirstKeystroke = didFinishSearch + + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + // Handle loading indicators for first keystroke + let loadingTask = isFirstKeystroke ? startLoadingIndicatorTask(threshold: loadingDelayThreshold) : nil + + // Debounce subsequent keystrokes + if !isFirstKeystroke { + try? await Task.sleep(nanoseconds: duration) + } + + guard !Task.isCancelled else { + loadingTask?.cancel() + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + loadingTask?.cancel() + } + + func executeSimpleDebouncedSearch(duration: UInt64, + loadingDelayThreshold: UInt64?, + searchTerm: String) async { + // Simple debouncing: Always debounce every keystroke + try? await Task.sleep(nanoseconds: duration) + + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + didFinishSearch = false + + if let threshold = loadingDelayThreshold { + await performSearchWithDelayedLoading(searchTerm: searchTerm, threshold: threshold) + } else { + searchable.clearSearchResults() + await searchable.performSearch(term: searchTerm) + } + + if !Task.isCancelled { + didFinishSearch = true + } + } + + func executeImmediateSearch(searchTerm: String) async { + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + } + + func startLoadingIndicatorTask(threshold: UInt64?) -> Task? { + if let threshold { + // With threshold: delay showing loading to prevent flicker for fast searches + return Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + } else { + // No threshold - show loading immediately for responsive feel + searchable.clearSearchResults() + return nil + } + } + + func performSearchWithDelayedLoading(searchTerm: String, threshold: UInt64) async { + // Create a loading task that shows indicators after threshold + let loadingTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + + await searchable.performSearch(term: searchTerm) + + // Cancel loading task if search completed before threshold + loadingTask.cancel() + } + + private func performSearchAndTrackCompletion(searchTerm: String) async { + didFinishSearch = false + await searchable.performSearch(term: searchTerm) + + if !Task.isCancelled { + didFinishSearch = true + } + } +} + /// A reusable search content view for POS items struct POSSearchContentView: View { @Environment(\.posAnalytics) private var analytics diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 8d2f488db1f..8c56105571e 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -2,6 +2,7 @@ import SwiftUI import struct WooFoundation.WooAnalyticsEvent import struct Yosemite.POSOrder import enum Yosemite.OrderPaymentMethod +import enum Yosemite.SearchDebounceStrategy struct POSOrderListView: View { @Binding var isSearching: Bool @@ -364,6 +365,18 @@ final class POSOrderSearchable: POSSearchable { [] } + var currentDebounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for order search to match original behavior: + // don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing + .smart() + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Orders use the same strategy for both modes + currentDebounceStrategy + } + func performSearch(term: String) async { await ordersController.searchOrders(searchTerm: term) } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index ead13993bec..131c0485644 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -18,6 +18,7 @@ import struct Yosemite.POSProduct import struct Yosemite.POSProductVariation import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItemType +import enum Yosemite.SearchDebounceStrategy import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol import enum Yosemite.PointOfSaleBarcodeScanError import Combine @@ -105,6 +106,8 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } func enableCoupons() async { } func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } @@ -118,6 +121,9 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } + func loadItems(base: ItemListBaseItem) async { switch base { case .root: diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index b1db2239528..a0f86f86046 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -156,6 +156,7 @@ public extension PersistedProduct { /// - Returns: An escaped pattern safe for use in LIKE queries private static func escapeSQLLikePattern(_ pattern: String) -> String { pattern + .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "%", with: "\\%") .replacingOccurrences(of: "_", with: "\\_") } diff --git a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift index abfae67cbbe..4fef3cd08da 100644 --- a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift +++ b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift @@ -1274,6 +1274,7 @@ public enum WooAnalyticsStat: String { case pointOfSaleKeyboardDismissedInSearch = "keyboard_dismissed_in_search" case pointOfSaleItemsNextPageLoaded = "items_next_page_loaded" case pointOfSaleSearchRemoteResultsFetched = "search_remote_results_fetched" + case pointOfSaleSearchResultsFetched = "search_results_fetched" case pointOfSaleBarcodeScanningMenuItemTapped = "barcode_scanning_menu_item_tapped" case pointOfSaleBarcodeScanningExplanationDialogShown = "barcode_scanning_explanation_dialog_shown" case pointOfSaleBarcodeScannerSetupFlowShown = "barcode_scanner_setup_flow_shown" diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift index 138b9b68702..38e5efa8a8a 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift @@ -6,6 +6,16 @@ import protocol Storage.StorageManagerType public protocol PointOfSaleCouponFetchStrategy { func fetchCoupons(pageNumber: Int) async throws -> PagedItems func fetchLocalCoupons() async throws -> [POSItem] + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSaleCouponFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } struct PointOfSaleDefaultCouponFetchStrategy: PointOfSaleCouponFetchStrategy { @@ -89,6 +99,12 @@ struct PointOfSaleSearchCouponFetchStrategy: PointOfSaleCouponFetchStrategy { self.analytics = analytics } + var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote coupon search + // No loading delay threshold - show loading immediately for responsive feel + .smart() + } + func fetchCoupons(pageNumber: Int) async throws -> PagedItems { let startTime = Date() try await couponStoreMethods.searchCoupons(siteID: siteID, diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift index 6166cf02c9a..b8119125d95 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift @@ -4,6 +4,7 @@ import class Networking.ProductVariationsRemote import class Networking.AlamofireNetwork import struct Combine.AnyPublisher import struct NetworkingCore.JetpackSite +import protocol Storage.GRDBManagerProtocol public protocol PointOfSaleItemFetchStrategyFactoryProtocol { func defaultStrategy(analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy @@ -16,17 +17,23 @@ public final class PointOfSaleItemFetchStrategyFactory: PointOfSaleItemFetchStra private let siteID: Int64 private let productsRemote: ProductsRemote private let variationsRemote: ProductVariationsRemote + private let grdbManager: GRDBManagerProtocol? + private let isLocalCatalogEnabled: Bool public init(siteID: Int64, credentials: Credentials?, selectedSite: AnyPublisher? = nil, - appPasswordSupportState: AnyPublisher? = nil) { + appPasswordSupportState: AnyPublisher? = nil, + grdbManager: GRDBManagerProtocol? = nil, + isLocalCatalogEnabled: Bool = false) { self.siteID = siteID let network = AlamofireNetwork(credentials: credentials, selectedSite: selectedSite, appPasswordSupportState: appPasswordSupportState) self.productsRemote = ProductsRemote(network: network) self.variationsRemote = ProductVariationsRemote(network: network) + self.grdbManager = grdbManager + self.isLocalCatalogEnabled = isLocalCatalogEnabled } public func defaultStrategy(analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy { @@ -37,11 +44,19 @@ public final class PointOfSaleItemFetchStrategyFactory: PointOfSaleItemFetchStra } public func searchStrategy(searchTerm: String, analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy { - PointOfSaleSearchPurchasableItemFetchStrategy(siteID: siteID, - searchTerm: searchTerm, - productsRemote: productsRemote, - variationsRemote: variationsRemote, - analytics: analytics) + // Use local search if explicitly enabled and GRDB manager is available + if isLocalCatalogEnabled, let grdbManager = grdbManager { + return PointOfSaleLocalSearchPurchasableItemFetchStrategy(siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: analytics) + } + return PointOfSaleSearchPurchasableItemFetchStrategy(siteID: siteID, + searchTerm: searchTerm, + productsRemote: productsRemote, + variationsRemote: variationsRemote, + analytics: analytics) } public func popularStrategy(pageSize: Int = 10) -> PointOfSalePurchasableItemFetchStrategy { diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift index 06a7a55aecd..186db3c6202 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift @@ -36,7 +36,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { return .init(items: itemMapper.mapProductsToPOSItems(products: products), hasMorePages: pagedProducts.hasMorePages, totalItems: pagedProducts.totalItems) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } @@ -55,7 +55,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { hasMorePages: pagedVariations.hasMorePages, totalItems: pagedVariations.totalItems ) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift index 7c8020039a1..58b32470436 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift @@ -3,10 +3,11 @@ import protocol Storage.GRDBManagerProtocol import protocol Networking.ProductVariationsRemoteProtocol /// Fetch strategy for searching products in the local GRDB catalog using SQL LIKE queries -public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { +struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { private let siteID: Int64 private let searchTerm: String private let grdbManager: GRDBManagerProtocol + // periphery:ignore - Reserved for future variation fetching from remote when not in local catalog private let variationsRemote: ProductVariationsRemoteProtocol private let analytics: POSItemFetchAnalyticsTracking private let pageSize: Int @@ -25,7 +26,16 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur self.pageSize = pageSize } - public func fetchProducts(pageNumber: Int) async throws -> PagedItems { + var debounceStrategy: SearchDebounceStrategy { + // Use simple debouncing for local search: always debounce to prevent excessive queries + // even though local searches are fast. 100ms provides responsive feel while preventing + // queries on every keystroke. Delay loading indicators by 150ms to avoid flicker for fast queries. + .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + } + + func fetchProducts(pageNumber: Int) async throws -> PagedItems { + let startTime = Date() + // Get total count and persisted products in one transaction let (persistedProducts, totalCount) = try await grdbManager.databaseConnection.read { db in let totalCount = try PersistedProduct @@ -49,12 +59,18 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur let hasMorePages = (pageNumber * pageSize) < totalCount + if pageNumber == 1 { + let milliseconds = Int(Date().timeIntervalSince(startTime) * Double(MSEC_PER_SEC)) + analytics.trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: milliseconds, + totalItems: totalCount) + } + return PagedItems(items: products, hasMorePages: hasMorePages, totalItems: totalCount) } - public func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { + func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { // Get total count and persisted variations in one transaction let (persistedVariations, totalCount) = try await grdbManager.databaseConnection.read { db in let totalCount = try PersistedProductVariation diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index f1178dbe7af..90ab4d1a4c4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -5,6 +5,17 @@ import protocol Networking.ProductVariationsRemoteProtocol public protocol PointOfSalePurchasableItemFetchStrategy { func fetchProducts(pageNumber: Int) async throws -> PagedItems func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems + + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSalePurchasableItemFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } public struct PointOfSaleDefaultPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { @@ -69,6 +80,14 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa self.analytics = analytics } + // periphery:ignore - Protocol requirement, used via protocol + public var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing. + // No loading delay threshold - show loading immediately for responsive feel. + .smart() + } + public func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() let pagedProducts = try await productsRemote.searchProductsForPointOfSale( diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift new file mode 100644 index 00000000000..7faa67bd7d3 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Defines the debouncing behavior for search input +public enum SearchDebounceStrategy: Equatable { + /// Smart debouncing: Skip debounce on first keystroke after a search completes, then debounce subsequent keystrokes. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for slow network searches where the first keystroke should show loading immediately. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds for subsequent keystrokes + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case smart(duration: UInt64 = 500 * NSEC_PER_MSEC, loadingDelayThreshold: UInt64? = nil) + + /// Simple debouncing: Always debounce every keystroke by the specified duration. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for fast local searches to prevent excessive queries and loading flicker. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case simple(duration: UInt64, loadingDelayThreshold: UInt64? = nil) + + /// Immediate: No debouncing, execute search on every keystroke. + /// Use when debouncing is not needed (e.g., non-search operations). + case immediate +} diff --git a/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift b/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift index 2bb1bb96eaf..d4d86354c21 100644 --- a/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift +++ b/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift @@ -12,4 +12,10 @@ public protocol POSItemFetchAnalyticsTracking { /// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds /// - totalItems: The total number of items found in the search func trackSearchRemoteResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) + + /// Tracks when a local search results fetch completes + /// - Parameters: + /// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds + /// - totalItems: The total number of items found in the search + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) } diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index 0484ea0d91c..5da65e0170e 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -1,4 +1,6 @@ @testable import PointOfSale +import Yosemite +import Foundation final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol { var loadItemsCalled = false @@ -7,6 +9,9 @@ final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtoc var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart() + func loadItems(base: ItemListBaseItem) async { loadItemsCalled = true loadItemsBase = base diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index 976a85379a5..4576e0606fb 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -2,11 +2,15 @@ import Foundation import Combine @testable import PointOfSale import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart() + func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async {} func loadItems(base: ItemListBaseItem) async { } @@ -15,5 +19,5 @@ final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchin func loadNextItems(base: ItemListBaseItem) async { } - func clearSearchItems(baseItem: PointOfSale.ItemListBaseItem) { } + func clearSearchItems(baseItem: ItemListBaseItem) { } } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift index 342768f606b..5a41f8c4093 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift @@ -5,6 +5,8 @@ final class MockPOSItemFetchAnalyticsTracking: POSItemFetchAnalyticsTracking { private(set) var spyTotalItems: Int? private(set) var spyMillisecondsSinceRequestSent: Int? private(set) var spySearchTotalItems: Int? + private(set) var spyLocalSearchMilliseconds: Int? + private(set) var spyLocalSearchTotalItems: Int? func trackItemsFetchComplete(totalItems: Int) { spyTotalItems = totalItems @@ -14,4 +16,9 @@ final class MockPOSItemFetchAnalyticsTracking: POSItemFetchAnalyticsTracking { spyMillisecondsSinceRequestSent = millisecondsSinceRequestSent spySearchTotalItems = totalItems } + + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + spyLocalSearchMilliseconds = millisecondsSinceRequestSent + spyLocalSearchTotalItems = totalItems + } } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift new file mode 100644 index 00000000000..13392276abd --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +import Networking +@testable import Storage +@testable import Yosemite + +@Suite("SearchDebounceStrategy Tests") +struct SearchDebounceStrategyTests { + + // MARK: - Equatable Tests + + @Test("Smart strategies with same duration are equal") + func test_smart_strategies_with_same_duration_are_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Smart strategies with different durations are not equal") + func test_smart_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with same duration and no threshold are equal") + func test_simple_strategies_with_same_duration_and_no_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with same duration and same threshold are equal") + func test_simple_strategies_with_same_duration_and_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with different durations are not equal") + func test_simple_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 200 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with different thresholds are not equal") + func test_simple_strategies_with_different_thresholds_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 400 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Immediate strategies are equal") + func test_immediate_strategies_are_equal() { + let strategy1: SearchDebounceStrategy = .immediate + let strategy2: SearchDebounceStrategy = .immediate + + #expect(strategy1 == strategy2) + } + + @Test("Different strategy types are not equal") + func test_different_strategy_types_are_not_equal() { + let smartStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let simpleStrategy: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let immediateStrategy: SearchDebounceStrategy = .immediate + + #expect(smartStrategy != simpleStrategy) + #expect(smartStrategy != immediateStrategy) + #expect(simpleStrategy != immediateStrategy) + } +} + +@Suite("Fetch Strategy Debouncing Tests") +struct FetchStrategyDebouncingTests { + private let siteID: Int64 = 123 + private let mockAnalytics = MockPOSItemFetchAnalyticsTracking() + private let mockProductsRemote = MockProductsRemote() + private let mockVariationsRemote = MockProductVariationsRemote() + private let mockCouponStoreMethods = MockCouponStoreMethods() + + // MARK: - Local Search Strategy Tests + + @Test("Local search strategy returns simple debouncing with loading delay threshold") + func test_local_search_strategy_returns_simple_debouncing_with_threshold() async throws { + let grdbManager = try GRDBManager() + + // Initialize site + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteID).insert(db) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + grdbManager: grdbManager, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Remote Search Strategy Tests + + @Test("Remote search strategy returns smart debouncing") + func test_remote_search_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Default Strategy Tests + + @Test("Default purchasable item strategy returns immediate debouncing") + func test_default_purchasable_item_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultPurchasableItemFetchStrategy( + siteID: siteID, + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + #expect(strategy.debounceStrategy == .immediate) + } + + // MARK: - Coupon Strategy Tests + + @Test("Search coupon strategy returns smart debouncing") + func test_search_coupon_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods(), + searchTerm: "test", + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + @Test("Default coupon strategy returns immediate debouncing") + func test_default_coupon_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods() + ) + + #expect(strategy.debounceStrategy == .immediate) + } +} diff --git a/WooCommerce/Classes/Analytics/TracksProvider.swift b/WooCommerce/Classes/Analytics/TracksProvider.swift index b2845b75087..832bafaef6f 100644 --- a/WooCommerce/Classes/Analytics/TracksProvider.swift +++ b/WooCommerce/Classes/Analytics/TracksProvider.swift @@ -150,6 +150,7 @@ private extension TracksProvider { WooAnalyticsStat.pointOfSaleKeyboardDismissedInSearch, WooAnalyticsStat.pointOfSaleItemsNextPageLoaded, WooAnalyticsStat.pointOfSaleSearchRemoteResultsFetched, + WooAnalyticsStat.pointOfSaleSearchResultsFetched, WooAnalyticsStat.pointOfSaleBarcodeScanningMenuItemTapped, WooAnalyticsStat.pointOfSaleBarcodeScanningExplanationDialogShown, WooAnalyticsStat.pointOfSaleBarcodeScannerSetupFlowShown, diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 809ed604240..89b525315c6 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -48,16 +48,21 @@ final class POSTabCoordinator { /// Local catalog eligibility service - created asynchronously during init private(set) var localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol? - private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { + /// Creates item fetch strategy factory with current local catalog eligibility + private func createItemFetchStrategyFactory(isLocalCatalogEnabled: Bool) -> PointOfSaleItemFetchStrategyFactory { PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials, selectedSite: defaultSitePublisher, - appPasswordSupportState: isAppPasswordSupported) - }() + appPasswordSupportState: isAppPasswordSupported, + grdbManager: isLocalCatalogEnabled ? ServiceLocator.grdbManager : nil, + isLocalCatalogEnabled: isLocalCatalogEnabled) + } - private lazy var posPopularItemFetchStrategyFactory: PointOfSaleFixedItemFetchStrategyFactory = { - PointOfSaleFixedItemFetchStrategyFactory(fixedStrategy: posItemFetchStrategyFactory.popularStrategy()) - }() + /// Creates popular item fetch strategy factory with current local catalog eligibility + private func createPopularItemFetchStrategyFactory(isLocalCatalogEnabled: Bool) -> PointOfSaleFixedItemFetchStrategyFactory { + let itemFactory = createItemFetchStrategyFactory(isLocalCatalogEnabled: isLocalCatalogEnabled) + return PointOfSaleFixedItemFetchStrategyFactory(fixedStrategy: itemFactory.popularStrategy()) + } private lazy var posCouponFetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory = { PointOfSaleCouponFetchStrategyFactory(siteID: siteID, @@ -251,8 +256,8 @@ private extension POSTabCoordinator { let posView = PointOfSaleEntryPointView( siteID: siteID, - itemFetchStrategyFactory: posItemFetchStrategyFactory, - popularItemFetchStrategyFactory: posPopularItemFetchStrategyFactory, + itemFetchStrategyFactory: createItemFetchStrategyFactory(isLocalCatalogEnabled: isLocalCatalogEligible), + popularItemFetchStrategyFactory: createPopularItemFetchStrategyFactory(isLocalCatalogEnabled: isLocalCatalogEligible), couponProvider: posCouponProvider, couponFetchStrategyFactory: posCouponFetchStrategyFactory, orderListFetchStrategyFactory: POSOrderListFetchStrategyFactory(