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// Résultat d'estimation — transparence totale : valeur, fourchette P10–P90,3// indice de confiance, fusion modèle/comparables, historique 2021→2026,4// caractéristiques du rôle et chaque comparable avec ses ajustements en dollars.5import SwiftUI6import Charts78struct EstimateView: View {9 let data: UnitEstimate10 @State private var reportURL: URL?11 @State private var loadingReport: String?12 @State private var reportError: String?1314 private var r: EstimateResult { data.result }1516 var body: some View {17 ScrollView {18 VStack(alignment: .leading, spacing: 18) {19 if let unit = data.unit {20 addressHeader(unit)21 }22 estimateCard23 fusionCard24 if let unit = data.unit {25 historyCard(unit)26 specsCard(unit)27 }28 if !r.comps.isEmpty {29 compsSection30 }31 if data.unit != nil {32 reportsCard33 }34 disclaimer35 }36 .padding(16)37 }38 .background(Color.vpPaper)39 .toolbar {40 ToolbarItem(placement: .principal) { Wordmark(size: 18) }41 }42 .toolbarBackground(Color.vpPaper, for: .navigationBar)43 .toolbarBackground(.visible, for: .navigationBar)44 .sheet(item: $reportURL) { url in45 PDFViewerView(url: url)46 }47 }4849 // MARK: - En-tête d'adresse5051 private func addressHeader(_ unit: PropertyUnit) -> some View {52 VStack(alignment: .leading, spacing: 6) {53 Kicker(text: PropType.label(for: unit.typeProp))54 Text(unit.adresse ?? "Adresse inconnue")55 .font(.vpDisplay(24))56 .foregroundStyle(Color.vpInk)57 .textCase(.uppercase)58 HStack(spacing: 8) {59 Text(unit.municipalite ?? "—")60 .font(.vpBody(14))61 .foregroundStyle(Color.vpInk2)62 if let libelle = unit.cubfLibelle {63 Text("· \(libelle)")64 .font(.vpBody(14))65 .foregroundStyle(Color.vpInk3)66 }67 }68 }69 }7071 // MARK: - Carte estimation7273 private var estimateCard: some View {74 VStack(alignment: .leading, spacing: 14) {75 Kicker(text: "Valeur marchande estimée")76 Text(Fmt.cad(r.estimate))77 .font(.vpDisplay(44))78 .foregroundStyle(Color.vpInk)79 .minimumScaleFactor(0.6)80 .lineLimit(1)8182 RangeBar(low: r.low, estimate: r.estimate, high: r.high)8384 HStack {85 VStack(alignment: .leading, spacing: 2) {86 Text("BAS (P10)").font(.vpMono(9.5)).foregroundStyle(Color.vpInk3)87 Text(Fmt.cad(r.low)).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk)88 }89 Spacer()90 ConfidenceBadge(level: r.confidenceLevel, pct: r.confidencePct)91 Spacer()92 VStack(alignment: .trailing, spacing: 2) {93 Text("HAUT (P90)").font(.vpMono(9.5)).foregroundStyle(Color.vpInk3)94 Text(Fmt.cad(r.high)).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk)95 }96 }9798 if let unit = data.unit, let role = unit.valeurRole {99 Rectangle().fill(Color.vpInk.opacity(0.15)).frame(height: 1)100 HStack {101 Text("Valeur au rôle 2024–2026")102 .font(.vpBody(13))103 .foregroundStyle(Color.vpInk2)104 Spacer()105 Text(Fmt.cad(role))106 .font(.vpMonoBold(13))107 .foregroundStyle(Color.vpInk)108 if role > 0 {109 Text(Fmt.signedCad(r.estimate - role))110 .font(.vpMono(11))111 .foregroundStyle(r.estimate >= role ? Color.vpGreen : Color.vpInk3)112 }113 }114 }115 }116 .padding(18)117 .vpCard()118 }119120 // MARK: - Fusion modèle + comparables121122 private var fusionCard: some View {123 VStack(alignment: .leading, spacing: 12) {124 Kicker(text: "Comment ce chiffre est calculé")125 if r.modelEstimate != nil {126 fusionRow(127 label: "Modèle hédonique",128 sub: "LightGBM · 18 variables · pré-calculé",129 value: r.modelEstimate,130 weight: r.modelWeight131 )132 }133 fusionRow(134 label: "Comparables ajustés",135 sub: "\(r.nCompsUsed) ventes réelles · médiane pondérée",136 value: r.compsEstimate,137 weight: 1 - r.modelWeight138 )139 if let disp = r.compsDispersionPct {140 Text("Dispersion des comparables : \(Fmt.pct(disp))")141 .font(.vpMono(10))142 .foregroundStyle(Color.vpInk3)143 }144 }145 .padding(18)146 .vpCard()147 }148149 private func fusionRow(label: String, sub: String, value: Double?, weight: Double) -> some View {150 HStack(alignment: .center, spacing: 12) {151 Text("\(Int((weight * 100).rounded())) %")152 .font(.vpDisplay(19))153 .foregroundStyle(Color.vpLime)154 .frame(width: 62, alignment: .leading)155 VStack(alignment: .leading, spacing: 2) {156 Text(label).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk)157 Text(sub).font(.vpBody(12)).foregroundStyle(Color.vpInk3)158 }159 Spacer()160 Text(Fmt.cad(value))161 .font(.vpMonoBold(14))162 .foregroundStyle(Color.vpInk)163 }164 .padding(12)165 .background(RoundedRectangle(cornerRadius: 10).fill(Color.vpSurface2))166 .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.vpInk.opacity(0.2), lineWidth: 1))167 }168169 // MARK: - Historique 2021→2026170171 private func historyCard(_ unit: PropertyUnit) -> some View {172 let points = unit.history.filter { $0.value != nil }173 return VStack(alignment: .leading, spacing: 12) {174 Kicker(text: "Historique des estimations")175 if points.count >= 2 {176 Chart(points) { p in177 LineMark(178 x: .value("Année", String(p.year)),179 y: .value("Valeur", p.value ?? 0)180 )181 .foregroundStyle(Color.vpInk)182 .lineStyle(StrokeStyle(lineWidth: 2.5))183 PointMark(184 x: .value("Année", String(p.year)),185 y: .value("Valeur", p.value ?? 0)186 )187 .foregroundStyle(p.year == 2026 ? Color.vpLime : Color.vpInk)188 }189 .chartYAxis {190 AxisMarks(position: .leading) { v in191 AxisGridLine().foregroundStyle(Color.vpInk.opacity(0.1))192 AxisValueLabel {193 if let d = v.as(Double.self) {194 Text(shortCad(d)).font(.vpMono(9)).foregroundStyle(Color.vpInk3)195 }196 }197 }198 }199 .chartXAxis {200 AxisMarks { _ in201 AxisValueLabel().font(.vpMono(9)).foregroundStyle(Color.vpInk2)202 }203 }204 .frame(height: 170)205206 if let first = points.first?.value, let last = points.last?.value, first > 0 {207 Text("Croissance \(points.first!.year)→\(points.last!.year) : \(Fmt.pct((last / first - 1) * 100))")208 .font(.vpMono(10.5))209 .foregroundStyle(Color.vpGreen)210 }211 } else {212 Text("Historique insuffisant pour cette unité.")213 .font(.vpBody(13))214 .foregroundStyle(Color.vpInk3)215 }216 }217 .padding(18)218 .vpCard()219 }220221 private func shortCad(_ v: Double) -> String {222 if abs(v) >= 1_000_000 {223 return String(format: "%.1f M$", locale: Locale(identifier: "fr_CA"), v / 1_000_000)224 }225 return "\(Int((v / 1000).rounded())) k$"226 }227228 // MARK: - Caractéristiques229230 private func specsCard(_ unit: PropertyUnit) -> some View {231 VStack(alignment: .leading, spacing: 12) {232 Kicker(text: "La propriété selon le rôle")233 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {234 specCell("Année de construction", unit.anneeConstruction.map(String.init) ?? "—")235 specCell("Aire des étages", Fmt.m2(unit.aireEtagesM2))236 specCell("Terrain", Fmt.m2(unit.superficieTerrainM2))237 specCell("Logements", unit.nbLogements.map(String.init) ?? "—")238 specCell("Étages", unit.specs.nbEtages.map(String.init) ?? "—")239 specCell("Façade du terrain", unit.specs.frontTerrainM.map { Fmt.num($0) + " m" } ?? "—")240 specCell("Lien physique", unit.specs.lienPhysique ?? "—")241 specCell("Genre", unit.specs.genreConstruction ?? "—")242 specCell("Valeur du terrain", Fmt.cad(unit.specs.valeurTerrain))243 specCell("Valeur du bâtiment", Fmt.cad(unit.specs.valeurBatiment))244 }245 }246 .padding(18)247 .vpCard()248 }249250 private func specCell(_ label: String, _ value: String) -> some View {251 VStack(alignment: .leading, spacing: 3) {252 Text(label.uppercased())253 .font(.vpMono(9))254 .tracking(0.6)255 .foregroundStyle(Color.vpInk3)256 Text(value)257 .font(.vpDisplayMedium(14.5))258 .foregroundStyle(Color.vpInk)259 }260 .frame(maxWidth: .infinity, alignment: .leading)261 }262263 // MARK: - Comparables264265 private var compsSection: some View {266 VStack(alignment: .leading, spacing: 12) {267 Kicker(text: "Ventes comparables — \(r.nCompsUsed) utilisées")268 Text("Chaque ajustement est montré en dollars. Aucune boîte noire.")269 .font(.vpBody(13))270 .foregroundStyle(Color.vpInk2)271 ForEach(topComps) { c in272 CompCard(comp: c)273 }274 }275 }276277 private var topComps: [AdjustedComp] {278 Array(r.comps.sorted { $0.weight > $1.weight }.prefix(8))279 }280281 // MARK: - Rapports PDF282283 private var reportsCard: some View {284 VStack(alignment: .leading, spacing: 12) {285 Kicker(text: "Rapports PDF")286 Button {287 downloadReport(pro: false)288 } label: {289 labelWithSpinner("Rapport standard (3 pages)", active: loadingReport == "standard")290 }291 .buttonStyle(VPPrimaryButtonStyle())292 Button {293 downloadReport(pro: true)294 } label: {295 labelWithSpinner("Rapport professionnel (6 pages)", active: loadingReport == "pro")296 }297 .buttonStyle(VPGhostButtonStyle())298 if let reportError {299 Text(reportError)300 .font(.vpBody(13))301 .foregroundStyle(Color.vpGreen)302 }303 }304 .padding(18)305 .vpCard()306 }307308 private func labelWithSpinner(_ title: String, active: Bool) -> some View {309 HStack(spacing: 10) {310 if active { ProgressView().controlSize(.small) }311 Text(active ? "Génération…" : title)312 }313 }314315 private func downloadReport(pro: Bool) {316 guard let unit = data.unit, loadingReport == nil else { return }317 loadingReport = pro ? "pro" : "standard"318 reportError = nil319 Task {320 defer { loadingReport = nil }321 do {322 let path = pro ? "/api/report/pro" : "/api/report"323 let name = "vrai-prix-\(pro ? "pro-" : "")\(unit.id).pdf"324 reportURL = try await VraiPrixAPI.shared.downloadReport(325 path: path,326 query: [URLQueryItem(name: "id", value: unit.id)],327 fileName: name328 )329 } catch {330 reportError = error.localizedDescription331 }332 }333 }334335 private var disclaimer: some View {336 Text("Estimation statistique à titre indicatif, fondée sur les rôles d'évaluation foncière du Québec (MAMH, licence ouverte) et 745 119 ventes réelles. Ne remplace pas une évaluation par un évaluateur agréé (OEAQ).")337 .font(.vpMono(9.5))338 .foregroundStyle(Color.vpInk3)339 .padding(.top, 2)340 .padding(.bottom, 12)341 }342}343344// URL identifiable pour .sheet(item:)345extension URL: @retroactive Identifiable {346 public var id: String { absoluteString }347}348349// MARK: - Fourchette P10–P90350351struct RangeBar: View {352 let low: Double353 let estimate: Double354 let high: Double355356 var body: some View {357 GeometryReader { geo in358 let w = geo.size.width359 let span = max(high - low, 1)360 let x = min(max((estimate - low) / span, 0), 1) * w361 ZStack(alignment: .leading) {362 Capsule()363 .fill(Color.vpLimeSoft)364 .frame(height: 10)365 .overlay(Capsule().stroke(Color.vpInk, lineWidth: 1.5))366 Circle()367 .fill(Color.vpLime)368 .overlay(Circle().stroke(Color.vpInk, lineWidth: 2))369 .frame(width: 20, height: 20)370 .offset(x: min(max(x - 10, 0), w - 20))371 }372 }373 .frame(height: 20)374 }375}376377// MARK: - Badge de confiance A–D378379struct ConfidenceBadge: View {380 let level: String381 let pct: Double382383 var body: some View {384 HStack(spacing: 8) {385 Text(level)386 .font(.vpDisplay(20))387 .foregroundStyle(Color.vpLime)388 .frame(width: 38, height: 38)389 .background(RoundedRectangle(cornerRadius: 9).fill(Color.vpInk))390 .rotationEffect(.degrees(-2))391 VStack(alignment: .leading, spacing: 1) {392 Text("CONFIANCE")393 .font(.vpMono(8.5))394 .tracking(1)395 .foregroundStyle(Color.vpInk3)396 Text("\(Int(pct.rounded())) %")397 .font(.vpDisplayMedium(15))398 .foregroundStyle(Color.vpInk)399 }400 }401 }402}403404// MARK: - Carte comparable405406struct CompCard: View {407 let comp: AdjustedComp408 @State private var expanded = false409410 var body: some View {411 VStack(alignment: .leading, spacing: 10) {412 HStack(alignment: .firstTextBaseline) {413 VStack(alignment: .leading, spacing: 2) {414 Text(comp.street ?? "Adresse non divulguée")415 .font(.vpDisplayMedium(15))416 .foregroundStyle(Color.vpInk)417 Text("\(comp.city ?? "—") · vendu en \(Fmt.monthYear(comp.date))")418 .font(.vpBody(12))419 .foregroundStyle(Color.vpInk2)420 }421 Spacer()422 VStack(alignment: .trailing, spacing: 2) {423 Text(Fmt.cad(comp.amount))424 .font(.vpMonoBold(14))425 .foregroundStyle(Color.vpInk)426 Text("PRIX PAYÉ")427 .font(.vpMono(8))428 .tracking(0.8)429 .foregroundStyle(Color.vpInk3)430 }431 }432433 HStack(spacing: 8) {434 chip(Fmt.distance(comp.distanceM))435 if let area = comp.floorArea { chip(Fmt.m2(area)) }436 if let year = comp.yearBuilt { chip(String(year)) }437 chip("poids \(Fmt.pct(comp.weight * 100, decimals: 0))")438 }439440 if expanded {441 VStack(spacing: 6) {442 adjRow("Conditions de marché", comp.adjTime)443 adjRow("Superficie", comp.adjArea)444 adjRow("Âge du bâtiment", comp.adjAge)445 Rectangle().fill(Color.vpInk.opacity(0.2)).frame(height: 1)446 HStack {447 Text("Prix ajusté")448 .font(.vpDisplayMedium(13.5))449 .foregroundStyle(Color.vpInk)450 Spacer()451 Text(Fmt.cad(comp.adjustedPrice))452 .font(.vpMonoBold(14))453 .foregroundStyle(Color.vpGreen)454 }455 }456 .padding(10)457 .background(RoundedRectangle(cornerRadius: 10).fill(Color.vpSurface2))458 }459460 Button {461 withAnimation(.snappy(duration: 0.2)) { expanded.toggle() }462 } label: {463 Text(expanded ? "Masquer les ajustements" : "Voir les ajustements en $")464 .font(.vpMonoBold(10.5))465 .tracking(0.6)466 .foregroundStyle(Color.vpGreen)467 }468 .buttonStyle(.plain)469 }470 .padding(14)471 .vpCard(corner: 12)472 }473474 private func chip(_ text: String) -> some View {475 Text(text)476 .font(.vpMono(10))477 .foregroundStyle(Color.vpInk2)478 .padding(.horizontal, 9)479 .padding(.vertical, 4)480 .background(Capsule().fill(Color.vpSurface))481 .overlay(Capsule().stroke(Color.vpInk.opacity(0.35), lineWidth: 1))482 }483484 private func adjRow(_ label: String, _ value: Double) -> some View {485 HStack {486 Text(label).font(.vpBody(13)).foregroundStyle(Color.vpInk2)487 Spacer()488 Text(Fmt.signedCad(value))489 .font(.vpMono(12.5))490 .foregroundStyle(abs(value) < 0.5 ? Color.vpInk3 : Color.vpInk)491 }492 }493}494