SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
12.7 KB · 277 lines swift
Raw Blame History
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