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