SPB Git

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%
6.6 KB · 176 lines swift
Raw Blame History
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