Skip to content

Commit 6efe96f

Browse files
committed
Split up debounce logic
1 parent 094c919 commit 6efe96f

File tree

1 file changed

+137
-126
lines changed

1 file changed

+137
-126
lines changed

Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift

Lines changed: 137 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -59,132 +59,7 @@ struct POSSearchField: View {
5959
.textInputAutocapitalization(.never)
6060
.focused($isSearchFieldFocused)
6161
.onChange(of: searchTerm) { oldValue, newValue in
62-
// Cancel any ongoing search
63-
searchTask?.cancel()
64-
65-
// Capture the debounce strategy synchronously BEFORE creating the task.
66-
// Use searchDebounceStrategy for non-empty search terms (actual searches),
67-
// and currentDebounceStrategy for empty terms (returning to popular products).
68-
let debounceStrategy = newValue.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy
69-
70-
searchTask = Task {
71-
// Apply debouncing based on the strategy captured at the start
72-
switch debounceStrategy {
73-
case .smart(let duration, let loadingDelayThreshold):
74-
// Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes
75-
// The loading indicator behavior depends on whether there's a threshold:
76-
// - With threshold: Show loading after threshold if search hasn't completed (prevents flicker)
77-
// - Without threshold: Show loading immediately (responsive feel)
78-
79-
let shouldDebounceNextSearchRequest = !didFinishSearch
80-
81-
// Early exit if search term is empty
82-
guard newValue.isNotEmpty else {
83-
didFinishSearch = true
84-
return
85-
}
86-
87-
// Start loading indicator task if we have a threshold and this is first keystroke
88-
let loadingTask: Task<Void, Never>?
89-
if !shouldDebounceNextSearchRequest {
90-
// First keystroke - handle loading indicators
91-
if let threshold = loadingDelayThreshold {
92-
// With threshold: delay showing loading to prevent flicker for fast searches
93-
loadingTask = Task { @MainActor in
94-
try? await Task.sleep(nanoseconds: threshold)
95-
if !Task.isCancelled {
96-
searchable.clearSearchResults()
97-
}
98-
}
99-
} else {
100-
// No threshold - show loading immediately for responsive feel
101-
searchable.clearSearchResults()
102-
loadingTask = nil
103-
}
104-
} else {
105-
// Subsequent keystrokes - loading already showing from previous search
106-
loadingTask = nil
107-
}
108-
109-
if shouldDebounceNextSearchRequest {
110-
try? await Task.sleep(nanoseconds: duration)
111-
}
112-
113-
// Now perform the search (common code for both and subsequent keystrokes)
114-
guard !Task.isCancelled else {
115-
loadingTask?.cancel()
116-
return
117-
}
118-
119-
didFinishSearch = false
120-
await searchable.performSearch(term: newValue)
121-
122-
// Cancel loading task if search completed (only relevant for first keystroke with threshold)
123-
loadingTask?.cancel()
124-
125-
if !Task.isCancelled {
126-
didFinishSearch = true
127-
}
128-
return
129-
130-
case .simple(let duration, let loadingDelayThreshold):
131-
// Simple debouncing: Always debounce
132-
try? await Task.sleep(nanoseconds: duration)
133-
134-
guard !Task.isCancelled else { return }
135-
guard newValue.isNotEmpty else {
136-
didFinishSearch = true
137-
return
138-
}
139-
140-
didFinishSearch = false
141-
142-
if let threshold = loadingDelayThreshold {
143-
// Delay showing loading indicators to avoid flicker for fast queries
144-
// Create a loading task that shows indicators after threshold
145-
let loadingTask = Task { @MainActor in
146-
try? await Task.sleep(nanoseconds: threshold)
147-
// Only show loading if not cancelled
148-
if !Task.isCancelled {
149-
searchable.clearSearchResults()
150-
}
151-
}
152-
153-
// Perform the search
154-
await searchable.performSearch(term: newValue)
155-
156-
// Cancel loading task if search completed before threshold
157-
loadingTask.cancel()
158-
} else {
159-
// No loading delay threshold - show loading immediately
160-
searchable.clearSearchResults()
161-
await searchable.performSearch(term: newValue)
162-
}
163-
164-
if !Task.isCancelled {
165-
didFinishSearch = true
166-
}
167-
return
168-
169-
case .immediate:
170-
// No debouncing
171-
break
172-
}
173-
174-
guard !Task.isCancelled else { return }
175-
176-
guard newValue.isNotEmpty else {
177-
didFinishSearch = true
178-
return
179-
}
180-
181-
didFinishSearch = false
182-
await searchable.performSearch(term: newValue)
183-
184-
if !Task.isCancelled {
185-
didFinishSearch = true
186-
}
187-
}
62+
handleSearchTermChange(newValue)
18863
}
18964
}
19065
.onChange(of: keyboardObserver.isKeyboardVisible) { _, isVisible in
@@ -197,6 +72,142 @@ struct POSSearchField: View {
19772
}
19873
}
19974

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+
200211
/// A reusable search content view for POS items
201212
struct POSSearchContentView<Content: View>: View {
202213
@Environment(\.posAnalytics) private var analytics

0 commit comments

Comments
 (0)