11import SwiftUI
22import enum Yosemite. POSItemType
33import enum Yosemite. POSItem
4+ import enum Yosemite. SearchDebounceStrategy
45
56/// Protocol defining search capabilities for POS items
67protocol POSSearchable {
78 var searchFieldPlaceholder : String { get }
89 /// Recent search history for the current item type
910 var searchHistory : [ String ] { get }
11+ /// The debouncing strategy currently active based on the controller's current state
12+ var currentDebounceStrategy : SearchDebounceStrategy { get }
13+ /// The debouncing strategy that will be used when performing a search (may differ from current strategy)
14+ var searchDebounceStrategy : SearchDebounceStrategy { get }
1015
1116 /// Called when a search should be performed
1217 /// - Parameter term: The search term to use
@@ -54,40 +59,7 @@ struct POSSearchField: View {
5459 . textInputAutocapitalization ( . never)
5560 . focused ( $isSearchFieldFocused)
5661 . onChange ( of: searchTerm) { oldValue, newValue in
57- // The debouncing logic is a little tricky, because the loading state is held in the controller.
58- // Arguably, we should use view state `isSearching` for this, so the UI is independent of the request timing.
59-
60- // As the user types, we don't want to send every keystroke to the remote, so we debounce the requests.
61- // However, we don't want to debounce the first keystroke of a new search, so that the loading
62- // state shows immediately and the UI feels responsive.
63-
64- // So, if the last search was finished, we don't debounce the first character. If it didn't
65- // finish i.e. it is still ongoing, we debounce the next keystrokes by 300ms. In either case,
66- // the ongoing search is redundant now there's a new search term, so we cancel it.
67- let shouldDebounceNextSearchRequest = !didFinishSearch
68- searchTask? . cancel ( )
69-
70- searchTask = Task {
71- if shouldDebounceNextSearchRequest {
72- try ? await Task . sleep ( nanoseconds: 500 * NSEC_PER_MSEC)
73- } else {
74- searchable. clearSearchResults ( )
75- }
76-
77- guard !Task. isCancelled else { return }
78-
79- guard newValue. isNotEmpty else {
80- didFinishSearch = true
81- return
82- }
83-
84- didFinishSearch = false
85- await searchable. performSearch ( term: newValue)
86-
87- if !Task. isCancelled {
88- didFinishSearch = true
89- }
90- }
62+ handleSearchTermChange ( newValue)
9163 }
9264 }
9365 . onChange ( of: keyboardObserver. isKeyboardVisible) { _, isVisible in
@@ -100,6 +72,142 @@ struct POSSearchField: View {
10072 }
10173}
10274
75+ // MARK: - Search Handling
76+ private extension POSSearchField {
77+ func handleSearchTermChange( _ newValue: String ) {
78+ searchTask? . cancel ( )
79+
80+ let debounceStrategy = selectDebounceStrategy ( for: newValue)
81+
82+ searchTask = Task {
83+ await executeSearchWithStrategy ( debounceStrategy, searchTerm: newValue)
84+ }
85+ }
86+
87+ func selectDebounceStrategy( for searchTerm: String ) -> SearchDebounceStrategy {
88+ // Use searchDebounceStrategy for non-empty search terms (actual searches),
89+ // and currentDebounceStrategy for empty terms (returning to popular products).
90+ searchTerm. isNotEmpty ? searchable. searchDebounceStrategy : searchable. currentDebounceStrategy
91+ }
92+
93+ func executeSearchWithStrategy( _ strategy: SearchDebounceStrategy , searchTerm: String ) async {
94+ switch strategy {
95+ case . smart( let duration, let loadingDelayThreshold) :
96+ await executeSmartDebouncedSearch ( duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm)
97+ case . simple( let duration, let loadingDelayThreshold) :
98+ await executeSimpleDebouncedSearch ( duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm)
99+ case . immediate:
100+ await executeImmediateSearch ( searchTerm: searchTerm)
101+ }
102+ }
103+
104+ func executeSmartDebouncedSearch( duration: UInt64 , loadingDelayThreshold: UInt64 ? , searchTerm: String ) async {
105+ // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes
106+ // The loading indicator behavior depends on whether there's a threshold:
107+ // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker)
108+ // - Without threshold: Show loading immediately (responsive feel)
109+
110+ let isFirstKeystroke = didFinishSearch
111+
112+ guard searchTerm. isNotEmpty else {
113+ didFinishSearch = true
114+ return
115+ }
116+
117+ // Handle loading indicators for first keystroke
118+ let loadingTask = isFirstKeystroke ? startLoadingIndicatorTask ( threshold: loadingDelayThreshold) : nil
119+
120+ // Debounce subsequent keystrokes
121+ if !isFirstKeystroke {
122+ try ? await Task . sleep ( nanoseconds: duration)
123+ }
124+
125+ guard !Task. isCancelled else {
126+ loadingTask? . cancel ( )
127+ return
128+ }
129+
130+ await performSearchAndTrackCompletion ( searchTerm: searchTerm)
131+ loadingTask? . cancel ( )
132+ }
133+
134+ func executeSimpleDebouncedSearch( duration: UInt64 ,
135+ loadingDelayThreshold: UInt64 ? ,
136+ searchTerm: String ) async {
137+ // Simple debouncing: Always debounce every keystroke
138+ try ? await Task . sleep ( nanoseconds: duration)
139+
140+ guard !Task. isCancelled else { return }
141+ guard searchTerm. isNotEmpty else {
142+ didFinishSearch = true
143+ return
144+ }
145+
146+ didFinishSearch = false
147+
148+ if let threshold = loadingDelayThreshold {
149+ await performSearchWithDelayedLoading ( searchTerm: searchTerm, threshold: threshold)
150+ } else {
151+ searchable. clearSearchResults ( )
152+ await searchable. performSearch ( term: searchTerm)
153+ }
154+
155+ if !Task. isCancelled {
156+ didFinishSearch = true
157+ }
158+ }
159+
160+ func executeImmediateSearch( searchTerm: String ) async {
161+ guard !Task. isCancelled else { return }
162+ guard searchTerm. isNotEmpty else {
163+ didFinishSearch = true
164+ return
165+ }
166+
167+ await performSearchAndTrackCompletion ( searchTerm: searchTerm)
168+ }
169+
170+ func startLoadingIndicatorTask( threshold: UInt64 ? ) -> Task < Void , Never > ? {
171+ if let threshold {
172+ // With threshold: delay showing loading to prevent flicker for fast searches
173+ return Task { @MainActor in
174+ try ? await Task . sleep ( nanoseconds: threshold)
175+ if !Task. isCancelled {
176+ searchable. clearSearchResults ( )
177+ }
178+ }
179+ } else {
180+ // No threshold - show loading immediately for responsive feel
181+ searchable. clearSearchResults ( )
182+ return nil
183+ }
184+ }
185+
186+ func performSearchWithDelayedLoading( searchTerm: String , threshold: UInt64 ) async {
187+ // Create a loading task that shows indicators after threshold
188+ let loadingTask = Task { @MainActor in
189+ try ? await Task . sleep ( nanoseconds: threshold)
190+ if !Task. isCancelled {
191+ searchable. clearSearchResults ( )
192+ }
193+ }
194+
195+ await searchable. performSearch ( term: searchTerm)
196+
197+ // Cancel loading task if search completed before threshold
198+ loadingTask. cancel ( )
199+ }
200+
201+ private func performSearchAndTrackCompletion( searchTerm: String ) async {
202+ didFinishSearch = false
203+ await searchable. performSearch ( term: searchTerm)
204+
205+ if !Task. isCancelled {
206+ didFinishSearch = true
207+ }
208+ }
209+ }
210+
103211/// A reusable search content view for POS items
104212struct POSSearchContentView < Content: View > : View {
105213 @Environment ( \. posAnalytics) private var analytics
0 commit comments