Skip to content
This repository was archived by the owner on Dec 9, 2025. It is now read-only.

Commit 49f2873

Browse files
committed
Move searching to a separate view-model, add debouncing, perform search in background actor
1 parent 6350683 commit 49f2873

3 files changed

Lines changed: 151 additions & 119 deletions

File tree

SF50 TOLD.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,8 @@
714714
"",
715715
"",
716716
"",
717+
"",
718+
"",
717719
);
718720
};
719721
/* End PBXShellScriptBuildPhase section */

SF50 TOLD/Views/Pickers/AirportPicker/SearchView.swift

Lines changed: 21 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -5,57 +5,45 @@ import SwiftUI
55

66
struct SearchView: View {
77
var onSelect: (Airport) -> Void
8-
@State private var searchText = ""
8+
@State private var viewModel: SearchViewModel?
9+
10+
@Environment(\.modelContext)
11+
private var modelContext
912

1013
var body: some View {
1114
NavigationStack {
12-
SearchResults(searchText: searchText, onSelect: onSelect)
13-
.searchable(text: $searchText)
15+
if let viewModel {
16+
SearchResults(viewModel: viewModel, onSelect: onSelect)
17+
.searchable(
18+
text: Binding(
19+
get: { viewModel.searchText },
20+
set: { viewModel.searchText = $0 }
21+
)
22+
)
23+
}
24+
}
25+
.onAppear {
26+
if viewModel == nil {
27+
viewModel = SearchViewModel(container: modelContext.container)
28+
}
1429
}
1530
}
1631
}
1732

1833
private struct SearchResults: View {
19-
var searchText: String
34+
var viewModel: SearchViewModel
2035
var onSelect: (Airport) -> Void
2136

22-
@Environment(\.modelContext)
23-
private var modelContext
24-
25-
@State private var airports: [Airport] = []
26-
@State private var isLoading = false
27-
@State private var searchTask: Task<Void, Never>?
28-
29-
private var sortedAirports: [Airport] {
30-
return airports.sorted { airport1, airport2 in
31-
let score1 = relevanceScore(for: airport1, searchText: searchText)
32-
let score2 = relevanceScore(for: airport2, searchText: searchText)
33-
if score1 != score2 { return score1 > score2 }
34-
35-
// If same relevance score, sort by name similarity (primary) + city similarity (secondary)
36-
let nameSim1 = nameSimilarity(airport1.name, to: searchText)
37-
let nameSim2 = nameSimilarity(airport2.name, to: searchText)
38-
let citySim1 = citySimilarity(airport1.city, to: searchText)
39-
let citySim2 = citySimilarity(airport2.city, to: searchText)
40-
let similarity1 = max(nameSim1, citySim1)
41-
let similarity2 = max(nameSim2, citySim2)
42-
if similarity1 != similarity2 { return similarity1 > similarity2 }
43-
44-
// Final tie-breaker: alphabetical by name
45-
return airport1.name.localizedStandardCompare(airport2.name) == .orderedAscending
46-
}
47-
}
48-
4937
var body: some View {
5038
Group {
51-
if sortedAirports.isEmpty {
39+
if viewModel.sortedAirports.isEmpty {
5240
List {
5341
Text("No results.")
5442
.foregroundStyle(.secondary)
5543
.multilineTextAlignment(.leading)
5644
}
5745
} else {
58-
List(sortedAirports) { (airport: Airport) in
46+
List(viewModel.sortedAirports) { (airport: Airport) in
5947
AirportRow(airport: airport, showFavoriteButton: true)
6048
.onTapGesture {
6149
onSelect(airport)
@@ -65,92 +53,6 @@ private struct SearchResults: View {
6553
}
6654
}
6755
}
68-
.onChange(of: searchText) { debouncedSearch() }
69-
.task { performSearch() }
70-
.onDisappear { searchTask?.cancel() }
71-
}
72-
73-
init(searchText: String, onSelect: @escaping (Airport) -> Void) {
74-
self.searchText = searchText
75-
self.onSelect = onSelect
76-
}
77-
78-
private func relevanceScore(for airport: Airport, searchText: String) -> Int {
79-
if airport.locationID == searchText.uppercased() { return 3 }
80-
if let ICAO_ID = airport.ICAO_ID, ICAO_ID == searchText.uppercased() { return 3 }
81-
if airport.name.localizedStandardContains(searchText) { return 2 }
82-
if let city = airport.city, city.localizedStandardContains(searchText) { return 1 }
83-
return 0
84-
}
85-
86-
private func nameSimilarity(_ name: String, to searchText: String) -> Double {
87-
if name.localizedStandardEquals(searchText) { return 1.0 }
88-
if name.localizedStandardHasPrefix(searchText) { return 0.8 }
89-
if name.localizedStandardContains(searchText) { return 0.6 }
90-
91-
// Calculate simple similarity based on common characters
92-
let commonChars = Set(name.localizedLowercase).intersection(Set(searchText.localizedLowercase))
93-
.count
94-
let totalChars = max(name.count, searchText.count)
95-
return Double(commonChars) / Double(totalChars) * 0.4
96-
}
97-
98-
private func citySimilarity(_ city: String?, to searchText: String) -> Double {
99-
guard let city else { return 0.0 }
100-
if city.localizedStandardEquals(searchText) { return 0.5 }
101-
if city.localizedStandardHasPrefix(searchText) { return 0.4 }
102-
if city.localizedStandardContains(searchText) { return 0.3 }
103-
return 0.0
104-
}
105-
106-
private func debouncedSearch() {
107-
searchTask?.cancel()
108-
searchTask = Task {
109-
// Wait 300ms before executing the search
110-
try? await Task.sleep(nanoseconds: 300_000_000)
111-
if !Task.isCancelled { performSearch() }
112-
}
113-
}
114-
115-
private func performSearch() {
116-
guard searchText.count > 2 else {
117-
airports = []
118-
return
119-
}
120-
121-
isLoading = true
122-
let searchTextCopy = searchText
123-
let container = modelContext.container
124-
125-
Task.detached {
126-
let context = ModelContext(container)
127-
let uppercaseText = searchTextCopy.uppercased()
128-
129-
let predicate = #Predicate<Airport> { airport in
130-
searchTextCopy.count > 2
131-
&& (airport.locationID == uppercaseText
132-
|| airport.name.localizedStandardContains(searchTextCopy)
133-
|| airport.ICAO_ID == uppercaseText
134-
|| airport.city?.localizedStandardContains(searchTextCopy) == true)
135-
}
136-
let descriptor = FetchDescriptor(predicate: predicate)
137-
138-
do {
139-
let results = try context.fetch(descriptor)
140-
await MainActor.run {
141-
// Only update if search text hasn't changed
142-
if searchTextCopy == searchText {
143-
airports = results
144-
isLoading = false
145-
}
146-
}
147-
} catch {
148-
await MainActor.run {
149-
airports = []
150-
isLoading = false
151-
}
152-
}
153-
}
15456
}
15557
}
15658

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import SF50_Shared
2+
import Sentry
3+
import SwiftData
4+
5+
@Observable
6+
@MainActor
7+
final class SearchViewModel: WithIdentifiableError {
8+
// Inputs
9+
var searchText: String = "" {
10+
didSet { debouncedSearch() }
11+
}
12+
13+
// Outputs
14+
private(set) var airports: [Airport] = []
15+
private(set) var isLoading = false
16+
var error: Error?
17+
18+
private let container: ModelContainer
19+
private var searchTask: Task<Void, Never>?
20+
21+
var sortedAirports: [Airport] {
22+
let sorted = airports.sorted { airport1, airport2 in
23+
let score1 = relevanceScore(for: airport1, searchText: searchText)
24+
let score2 = relevanceScore(for: airport2, searchText: searchText)
25+
if score1 != score2 { return score1 > score2 }
26+
27+
// If same relevance score, sort by name similarity (primary) + city similarity (secondary)
28+
let nameSim1 = nameSimilarity(airport1.name, to: searchText)
29+
let nameSim2 = nameSimilarity(airport2.name, to: searchText)
30+
let citySim1 = citySimilarity(airport1.city, to: searchText)
31+
let citySim2 = citySimilarity(airport2.city, to: searchText)
32+
let similarity1 = max(nameSim1, citySim1)
33+
let similarity2 = max(nameSim2, citySim2)
34+
if similarity1 != similarity2 { return similarity1 > similarity2 }
35+
36+
// Final tie-breaker: alphabetical by name
37+
return airport1.name.localizedStandardCompare(airport2.name) == .orderedAscending
38+
}
39+
40+
// Limit to top 10 results after sorting
41+
return Array(sorted.prefix(10))
42+
}
43+
44+
init(container: ModelContainer) {
45+
self.container = container
46+
}
47+
48+
private func debouncedSearch() {
49+
searchTask?.cancel()
50+
searchTask = Task {
51+
// Wait 250ms before executing the search
52+
try? await Task.sleep(nanoseconds: 250_000_000)
53+
if !Task.isCancelled { await performSearch() }
54+
}
55+
}
56+
57+
private func performSearch() {
58+
guard searchText.count > 2 else {
59+
airports = []
60+
return
61+
}
62+
63+
isLoading = true
64+
let searchTextCopy = searchText
65+
66+
Task.detached { [container] in
67+
let context = ModelContext(container)
68+
let uppercaseText = searchTextCopy.uppercased()
69+
70+
let predicate = #Predicate<Airport> { airport in
71+
airport.locationID == uppercaseText
72+
|| airport.name.localizedStandardContains(searchTextCopy)
73+
|| airport.ICAO_ID == uppercaseText
74+
|| airport.city?.localizedStandardContains(searchTextCopy) == true
75+
}
76+
77+
let descriptor = FetchDescriptor(predicate: predicate)
78+
79+
do {
80+
let results = try context.fetch(descriptor)
81+
82+
await MainActor.run {
83+
// Only update if search text hasn't changed
84+
if searchTextCopy == self.searchText {
85+
self.airports = results
86+
self.isLoading = false
87+
self.error = nil
88+
}
89+
}
90+
} catch {
91+
await MainActor.run {
92+
SentrySDK.capture(error: error)
93+
self.airports = []
94+
self.isLoading = false
95+
self.error = error
96+
}
97+
}
98+
}
99+
}
100+
101+
private func relevanceScore(for airport: Airport, searchText: String) -> Int {
102+
if airport.locationID == searchText.uppercased() { return 3 }
103+
if let ICAO_ID = airport.ICAO_ID, ICAO_ID == searchText.uppercased() { return 3 }
104+
if airport.name.localizedStandardContains(searchText) { return 2 }
105+
if let city = airport.city, city.localizedStandardContains(searchText) { return 1 }
106+
return 0
107+
}
108+
109+
private func nameSimilarity(_ name: String, to searchText: String) -> Double {
110+
if name.localizedStandardEquals(searchText) { return 1.0 }
111+
if name.localizedStandardHasPrefix(searchText) { return 0.8 }
112+
if name.localizedStandardContains(searchText) { return 0.6 }
113+
114+
// Calculate simple similarity based on common characters
115+
let commonChars = Set(name.localizedLowercase).intersection(Set(searchText.localizedLowercase))
116+
.count
117+
let totalChars = max(name.count, searchText.count)
118+
return Double(commonChars) / Double(totalChars) * 0.4
119+
}
120+
121+
private func citySimilarity(_ city: String?, to searchText: String) -> Double {
122+
guard let city else { return 0.0 }
123+
if city.localizedStandardEquals(searchText) { return 0.5 }
124+
if city.localizedStandardHasPrefix(searchText) { return 0.4 }
125+
if city.localizedStandardContains(searchText) { return 0.3 }
126+
return 0.0
127+
}
128+
}

0 commit comments

Comments
 (0)