// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Recherche d'adresse (FTS5 côté serveur) avec autocomplétion débouncée, // puis navigation vers l'estimation complète. import SwiftUI struct SearchView: View { @State private var query = "" @State private var results: [SearchResult] = [] @State private var searching = false @State private var errorMessage: String? var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 18) { header VStack(alignment: .leading, spacing: 10) { Kicker(text: "Adresse au Québec") TextField("Ex. : 10005 rue Verville, Montréal", text: $query) .vpField() .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.search) Text("3 747 008 unités d'évaluation · 745 119 ventes réelles") .font(.vpMono(10.5)) .foregroundStyle(Color.vpInk3) } .padding(16) .vpCard() if searching { HStack(spacing: 10) { ProgressView() Text("Recherche…") .font(.vpBody(14)) .foregroundStyle(Color.vpInk2) } .frame(maxWidth: .infinity) .padding(.top, 8) } if let errorMessage { Text(errorMessage) .font(.vpBody(14)) .foregroundStyle(Color.vpGreen) } if !results.isEmpty { VStack(spacing: 0) { ForEach(Array(results.enumerated()), id: \.element.id) { index, r in NavigationLink(value: r) { resultRow(index: index, r: r) } .buttonStyle(.plain) if r.id != results.last?.id { Rectangle() .fill(Color.vpInk.opacity(0.15)) .frame(height: 1) } } } .vpCard() } else if query.trimmingCharacters(in: .whitespaces).count >= 3, !searching { Text("Aucun résultat — précisez le numéro civique et la rue.") .font(.vpBody(14)) .foregroundStyle(Color.vpInk3) } disclaimer } .padding(16) } .background(Color.vpPaper) .navigationDestination(for: SearchResult.self) { r in EstimateLoaderView(searchResult: r) } .toolbar { ToolbarItem(placement: .principal) { Wordmark(size: 20) } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(Color.vpPaper, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } .task(id: query) { await runSearch() } } private var header: some View { VStack(alignment: .leading, spacing: 8) { Text("La vraie valeur de votre propriété") .font(.vpDisplay(28)) .foregroundStyle(Color.vpInk) .textCase(.uppercase) .lineSpacing(2) Text("Sans boîte noire — chaque comparable, chaque ajustement en dollars, la fourchette et l'indice de confiance.") .font(.vpBody(14.5)) .foregroundStyle(Color.vpInk2) } .padding(.top, 6) } private func resultRow(index: Int, r: SearchResult) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(String(format: "%02d", index + 1)) .font(.vpMonoBold(11)) .foregroundStyle(Color.vpGreen) VStack(alignment: .leading, spacing: 3) { Text(fullAddress(r)) .font(.vpDisplayMedium(15.5)) .foregroundStyle(Color.vpInk) .multilineTextAlignment(.leading) HStack(spacing: 8) { Text(r.municipalite ?? "—") .font(.vpBody(12.5)) .foregroundStyle(Color.vpInk2) Text(PropType.label(for: r.typeProp).uppercased()) .font(.vpMono(9.5)) .tracking(0.8) .foregroundStyle(Color.vpGreen) } } Spacer() Image(systemName: "arrow.right") .font(.system(size: 13, weight: .bold)) .foregroundStyle(Color.vpInk) } .padding(.horizontal, 14) .padding(.vertical, 12) .contentShape(Rectangle()) } private func fullAddress(_ r: SearchResult) -> String { var s = r.adresse ?? "—" if let apt = r.apt, !apt.isEmpty { s += ", app. \(apt)" } return s } private var disclaimer: some View { Text("Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un évaluateur agréé (OEAQ).") .font(.vpMono(9.5)) .foregroundStyle(Color.vpInk3) .padding(.top, 4) } private func runSearch() async { let q = query.trimmingCharacters(in: .whitespaces) guard q.count >= 3 else { results = [] searching = false errorMessage = nil return } // debounce 300 ms — la tâche est annulée si la saisie continue try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } searching = true errorMessage = nil do { let found = try await VraiPrixAPI.shared.search(q) guard !Task.isCancelled else { return } results = found } catch is CancellationError { return } catch { if !Task.isCancelled { errorMessage = error.localizedDescription } } searching = false } } #Preview { SearchView() }