spb/vrai-prix-ios Public
App iOS native de Vrai-Prix — estimation immobilière transparente pour le Québec (SwiftUI, MapKit, Swift Charts)
Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Recherche d'adresse (FTS5 côté serveur) avec autocomplétion débouncée,3// puis navigation vers l'estimation complète.4import SwiftUI56struct SearchView: View {7 @State private var query = ""8 @State private var results: [SearchResult] = []9 @State private var searching = false10 @State private var errorMessage: String?1112 var body: some View {13 NavigationStack {14 ScrollView {15 VStack(alignment: .leading, spacing: 18) {16 header1718 VStack(alignment: .leading, spacing: 10) {19 Kicker(text: "Adresse au Québec")20 TextField("Ex. : 10005 rue Verville, Montréal", text: $query)21 .vpField()22 .autocorrectionDisabled()23 .textInputAutocapitalization(.never)24 .submitLabel(.search)25 Text("3 747 008 unités d'évaluation · 745 119 ventes réelles")26 .font(.vpMono(10.5))27 .foregroundStyle(Color.vpInk3)28 }29 .padding(16)30 .vpCard()3132 if searching {33 HStack(spacing: 10) {34 ProgressView()35 Text("Recherche…")36 .font(.vpBody(14))37 .foregroundStyle(Color.vpInk2)38 }39 .frame(maxWidth: .infinity)40 .padding(.top, 8)41 }4243 if let errorMessage {44 Text(errorMessage)45 .font(.vpBody(14))46 .foregroundStyle(Color.vpGreen)47 }4849 if !results.isEmpty {50 VStack(spacing: 0) {51 ForEach(Array(results.enumerated()), id: \.element.id) { index, r in52 NavigationLink(value: r) {53 resultRow(index: index, r: r)54 }55 .buttonStyle(.plain)56 if r.id != results.last?.id {57 Rectangle()58 .fill(Color.vpInk.opacity(0.15))59 .frame(height: 1)60 }61 }62 }63 .vpCard()64 } else if query.trimmingCharacters(in: .whitespaces).count >= 3, !searching {65 Text("Aucun résultat — précisez le numéro civique et la rue.")66 .font(.vpBody(14))67 .foregroundStyle(Color.vpInk3)68 }6970 disclaimer71 }72 .padding(16)73 }74 .background(Color.vpPaper)75 .navigationDestination(for: SearchResult.self) { r in76 EstimateLoaderView(searchResult: r)77 }78 .toolbar {79 ToolbarItem(placement: .principal) { Wordmark(size: 20) }80 }81 .navigationBarTitleDisplayMode(.inline)82 .toolbarBackground(Color.vpPaper, for: .navigationBar)83 .toolbarBackground(.visible, for: .navigationBar)84 }85 .task(id: query) {86 await runSearch()87 }88 }8990 private var header: some View {91 VStack(alignment: .leading, spacing: 8) {92 Text("La vraie valeur de votre propriété")93 .font(.vpDisplay(28))94 .foregroundStyle(Color.vpInk)95 .textCase(.uppercase)96 .lineSpacing(2)97 Text("Sans boîte noire — chaque comparable, chaque ajustement en dollars, la fourchette et l'indice de confiance.")98 .font(.vpBody(14.5))99 .foregroundStyle(Color.vpInk2)100 }101 .padding(.top, 6)102 }103104 private func resultRow(index: Int, r: SearchResult) -> some View {105 HStack(alignment: .firstTextBaseline, spacing: 12) {106 Text(String(format: "%02d", index + 1))107 .font(.vpMonoBold(11))108 .foregroundStyle(Color.vpGreen)109 VStack(alignment: .leading, spacing: 3) {110 Text(fullAddress(r))111 .font(.vpDisplayMedium(15.5))112 .foregroundStyle(Color.vpInk)113 .multilineTextAlignment(.leading)114 HStack(spacing: 8) {115 Text(r.municipalite ?? "—")116 .font(.vpBody(12.5))117 .foregroundStyle(Color.vpInk2)118 Text(PropType.label(for: r.typeProp).uppercased())119 .font(.vpMono(9.5))120 .tracking(0.8)121 .foregroundStyle(Color.vpGreen)122 }123 }124 Spacer()125 Image(systemName: "arrow.right")126 .font(.system(size: 13, weight: .bold))127 .foregroundStyle(Color.vpInk)128 }129 .padding(.horizontal, 14)130 .padding(.vertical, 12)131 .contentShape(Rectangle())132 }133134 private func fullAddress(_ r: SearchResult) -> String {135 var s = r.adresse ?? "—"136 if let apt = r.apt, !apt.isEmpty { s += ", app. \(apt)" }137 return s138 }139140 private var disclaimer: some View {141 Text("Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un évaluateur agréé (OEAQ).")142 .font(.vpMono(9.5))143 .foregroundStyle(Color.vpInk3)144 .padding(.top, 4)145 }146147 private func runSearch() async {148 let q = query.trimmingCharacters(in: .whitespaces)149 guard q.count >= 3 else {150 results = []151 searching = false152 errorMessage = nil153 return154 }155 // debounce 300 ms — la tâche est annulée si la saisie continue156 try? await Task.sleep(for: .milliseconds(300))157 guard !Task.isCancelled else { return }158 searching = true159 errorMessage = nil160 do {161 let found = try await VraiPrixAPI.shared.search(q)162 guard !Task.isCancelled else { return }163 results = found164 } catch is CancellationError {165 return166 } catch {167 if !Task.isCancelled { errorMessage = error.localizedDescription }168 }169 searching = false170 }171}172173#Preview {174 SearchView()175}176