Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// VraiPrixExplorer.swift — l'expérience Vrai-Prix NATIVE macOS, fidèle au3// site : recherche d'adresse (FTS 3,7 M unités, /api/search) → fiche4// d'estimation complète (/api/estimate : estimation, fourchette, confiance5// A-D, valeur au rôle, historique 2021-2026 en barres, portrait).6// Adapté de l'app iOS KA (VraiPrixView.swift).7import SwiftUI89struct VPResult: Identifiable, Hashable {10 let id: String11 let adresse: String12 let municipalite: String13 let type: String?14}1516struct VraiPrixExplorer: View {17 let universe: Universe18 @EnvironmentObject private var pulse: EcosystemPulse19 @Environment(\.colorScheme) private var scheme20 @State private var query = ""21 @State private var results: [VPResult] = []22 @State private var selected: VPResult?23 @State private var searching = false2425 var body: some View {26 VStack(spacing: 0) {27 hero28 Divider().opacity(0.4)29 HStack(spacing: 0) {30 list31 .frame(width: 420)32 Divider().opacity(0.4)33 Group {34 if let r = selected {35 EstimatePane(result: r, universe: universe)36 .id(r.id)37 } else {38 KAEmptyState(symbol: "chart.line.uptrend.xyaxis",39 title: "Estimez n'importe quelle adresse du Québec",40 message: "Cherchez une adresse à gauche — le moteur Vrai-Prix calcule la valeur réelle, la fourchette et la confiance.")41 .frame(maxHeight: .infinity)42 .background(KATheme.paper(scheme))43 }44 }45 .frame(maxWidth: .infinity)46 }47 }48 }4950 private var hero: some View {51 VStack(alignment: .leading, spacing: 10) {52 HStack(alignment: .firstTextBaseline, spacing: 12) {53 KAWordmark(universe: universe, size: 24)54 Text("LA VALEUR RÉELLE · \(pulse.totals[universe.id].map { $0.fr } ?? "3 747 008") PROPRIÉTÉS")55 .font(.system(size: 10, design: .monospaced).weight(.bold))56 .foregroundStyle(universe.accent)57 Spacer()58 Link(destination: universe.baseURL) {59 Label("Ouvrir le site", systemImage: "safari")60 .font(.caption.weight(.semibold))61 }62 .foregroundStyle(universe.accent)63 }64 KASearchField(prompt: "1305 Chemin Sainte-Foy, Québec…", text: $query) {65 Task { await search() }66 }67 }68 .padding(.horizontal, 16).padding(.vertical, 12)69 }7071 private var list: some View {72 ScrollView {73 LazyVStack(spacing: 9) {74 if searching {75 ForEach(0..<4, id: \.self) { _ in KASkeletonRow() }76 } else if results.isEmpty && !query.isEmpty {77 KAEmptyState(symbol: "house.slash", title: "Adresse introuvable",78 message: "Essayez numéro + rue + ville.")79 } else if results.isEmpty {80 KAEmptyState(symbol: "magnifyingglass",81 title: "Cherchez une adresse",82 message: "Le moteur fouille 3,7 M d'unités d'évaluation du Québec.")83 } else {84 ForEach(results) { r in85 Button { selected = r } label: {86 VStack(alignment: .leading, spacing: 3) {87 Text(r.adresse).font(.subheadline.weight(.semibold))88 HStack {89 Text(r.municipalite).font(.caption).foregroundStyle(.secondary)90 if let t = r.type { KAChip(text: t, accent: universe.accent) }91 }92 }93 .frame(maxWidth: .infinity, alignment: .leading)94 .padding(11)95 .kaCard(accent: universe.accent)96 .overlay(97 RoundedRectangle(cornerRadius: 12, style: .continuous)98 .strokeBorder(universe.accent, lineWidth: selected?.id == r.id ? 2.4 : 0)99 .padding(.trailing, 4).padding(.bottom, 4)100 )101 }102 .buttonStyle(KAPressStyle())103 }104 }105 }106 .padding(14)107 }108 .background(KATheme.paper(scheme))109 }110111 private func search() async {112 let q = query.trimmingCharacters(in: .whitespaces)113 guard q.count > 2 else { return }114 searching = true115 defer { searching = false }116 guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/search") else { return }117 comps.queryItems = [.init(name: "q", value: q)]118 guard let url = comps.url, let root = try? await APIClient.shared.json(url),119 let arr = root.object?["results"]?.array else { results = []; return }120 results = arr.compactMap { v in121 guard let o = v.object, let id = o.str("id"), let ad = o.str("adresse") else { return nil }122 return VPResult(id: id,123 adresse: [ad, o.str("apt")].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: " app. "),124 municipalite: o.str("municipalite") ?? "", type: o.str("typeProp"))125 }126 if selected == nil { selected = results.first }127 }128}129130// MARK: - Fiche d'estimation131132struct EstimatePane: View {133 let result: VPResult134 let universe: Universe135 @State private var unit: [String: JSONValue]?136 @State private var res: [String: JSONValue]?137 @State private var failed = false138 @Environment(\.colorScheme) private var scheme139140 var body: some View {141 ScrollView {142 VStack(alignment: .leading, spacing: 16) {143 Text(result.adresse).font(.title2.weight(.bold)).textSelection(.enabled)144 Text(result.municipalite).font(.subheadline).foregroundStyle(.secondary)145146 if let res {147 estimateCard(res)148 if let u = unit { historyCard(u); portraitCard(u) }149 Link(destination: URL(string: "https://www.vrai-prix.com/estimation/\(result.id)")!) {150 Label("Fiche complète + rapport PDF sur Vrai-Prix", systemImage: "doc.richtext")151 .font(.headline)152 .frame(maxWidth: .infinity).padding(.vertical, 13)153 .background(universe.accent, in: RoundedRectangle(cornerRadius: 11, style: .continuous))154 .foregroundStyle(.white)155 }156 .buttonStyle(.plain)157 } else if failed {158 KAEmptyState(symbol: "wifi.exclamationmark", title: "Estimation indisponible",159 message: "Le service ne répond pas — réessayez.")160 } else {161 ProgressView("Le moteur estime…").frame(maxWidth: .infinity).padding(40)162 }163 }164 .padding(18)165 }166 .background(KATheme.paper(scheme))167 .task { await load() }168 }169170 private func estimateCard(_ r: [String: JSONValue]) -> some View {171 VStack(alignment: .leading, spacing: 8) {172 Text("ESTIMATION VRAI-PRIX")173 .font(.system(size: 10, design: .monospaced).weight(.bold))174 .foregroundStyle(Color(hex: "#f5f3ee").opacity(0.6))175 Text((r.num("estimate") ?? 0).money0)176 .font(.system(size: 38, weight: .bold, design: .rounded))177 .foregroundStyle(universe.accent)178 .minimumScaleFactor(0.6).lineLimit(1)179 if let lo = r.num("low"), let hi = r.num("high") {180 Text("Fourchette \(lo.money0) – \(hi.money0)")181 .font(.subheadline).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.85))182 }183 HStack(spacing: 8) {184 if let level = r.str("confidenceLevel") {185 Text("Confiance \(level)")186 .font(.system(.caption, design: .monospaced).weight(.bold))187 .padding(.horizontal, 10).padding(.vertical, 4)188 .background(confColor(level), in: Capsule())189 .foregroundStyle(.black)190 }191 if let pct = r.num("confidencePct") {192 Text("\(Int(pct)) %").font(.caption).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.7))193 }194 Spacer()195 if let role = unit?.num("valeurRole") {196 VStack(alignment: .trailing, spacing: 0) {197 Text("Rôle 2026").font(.system(size: 9, design: .monospaced)).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.55))198 Text(role.money0).font(.caption.weight(.bold)).foregroundStyle(Color(hex: "#f5f3ee"))199 }200 }201 }202 }203 .padding(18)204 .frame(maxWidth: .infinity, alignment: .leading)205 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 16, style: .continuous))206 }207208 private func historyCard(_ u: [String: JSONValue]) -> some View {209 Group {210 if let hist = u["history"]?.array, hist.count > 1 {211 VStack(alignment: .leading, spacing: 10) {212 Text("Valeur au rôle, 2021 → 2026").font(.headline)213 let pts: [(String, Double)] = hist.compactMap { h in214 guard let o = h.object, let y = o.num("year"), let v = o.num("value") else { return nil }215 return (String(Int(y)), v)216 }217 let maxV = pts.map(\.1).max() ?? 1218 HStack(alignment: .bottom, spacing: 10) {219 ForEach(pts, id: \.0) { (year, v) in220 VStack(spacing: 4) {221 Text(v.compact).font(.system(size: 8, design: .monospaced)).foregroundStyle(.secondary)222 RoundedRectangle(cornerRadius: 3)223 .fill(universe.accent.opacity(year == pts.last?.0 ? 1 : 0.45))224 .frame(height: max(CGFloat(v / maxV) * 90, 6))225 Text(year).font(.system(size: 9, design: .monospaced).weight(.bold))226 }227 .frame(maxWidth: .infinity)228 }229 }230 }231 .padding(14).kaCard(accent: universe.accent)232 }233 }234 }235236 private func portraitCard(_ u: [String: JSONValue]) -> some View {237 let rows: [(String, String?)] = [238 ("Type", u.str("cubfLibelle") ?? u.str("typeProp")),239 ("Année de construction", u.num("anneeConstruction").map { String(Int($0)) }),240 ("Aire des étages", u.num("aireEtagesM2").map { "\(Int($0)) m²" }),241 ("Terrain", u.num("superficieTerrainM2").map { "\(Int($0)) m²" }),242 ("Logements", u.num("nbLogements").map { String(Int($0)) }),243 ]244 return VStack(spacing: 0) {245 ForEach(rows.filter { $0.1 != nil }, id: \.0) { (label, value) in246 HStack {247 Text(label).font(.subheadline).foregroundStyle(.secondary)248 Spacer()249 Text(value ?? "").font(.subheadline.weight(.semibold))250 }251 .padding(.vertical, 9).padding(.horizontal, 14)252 Divider().opacity(label == rows.last?.0 ? 0 : 1)253 }254 }255 .kaCard()256 }257258 private func confColor(_ level: String) -> Color {259 switch level {260 case "A": return Color(hex: "#d9f26b")261 case "B": return Color(hex: "#a8e063")262 case "C": return Color(hex: "#e8a33d")263 default: return Color(hex: "#ff8a80")264 }265 }266267 private func load() async {268 guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/estimate") else { return }269 comps.queryItems = [.init(name: "id", value: result.id)]270 guard let url = comps.url, let root = try? await APIClient.shared.json(url, ttl: 3600),271 let obj = root.object else { failed = true; return }272 unit = obj["unit"]?.object273 res = obj["result"]?.object274 if res == nil { failed = true }275 }276}277