SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
12.1 KB · 264 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// VraiPrixView.swift — l'expérience Vrai-Prix NATIVE, fidèle au site :3// recherche d'adresse (FTS 3,7 M unités) → fiche d'estimation complète4// (estimation, fourchette basse-haute, indice de confiance A-D, valeur au5// rôle, historique 2021-2026 en barres, portrait de la propriété).6import SwiftUI78struct VPResult: Identifiable, Hashable {9    let id: String10    let adresse: String11    let municipalite: String12    let type: String?13}1415struct VraiPrixView: View {16    let universe: Universe17    @State private var query = ""18    @State private var results: [VPResult] = []19    @State private var searching = false20    @Environment(\.colorScheme) private var scheme2122    var body: some View {23        ScrollView {24            VStack(alignment: .leading, spacing: 16) {25                VStack(alignment: .leading, spacing: 6) {26                    Text("LA VALEUR RÉELLE · 3 747 008 PROPRIÉTÉS")27                        .font(.system(size: 10, design: .monospaced).weight(.bold))28                        .foregroundStyle(universe.accent)29                    Text("Estimez n'importe quelle adresse du Québec")30                        .font(.title3.weight(.bold))31                }32                HStack(spacing: 8) {33                    Image(systemName: "magnifyingglass").foregroundStyle(.secondary)34                    TextField("1305 Chemin Sainte-Foy, Québec…", text: $query)35                        .autocorrectionDisabled()36                        .submitLabel(.search)37                        .onSubmit { Task { await search() } }38                    if searching { ProgressView() }39                }40                .padding(13)41                .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 12, style: .continuous))42                .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.4), lineWidth: 1.3))4344                if results.isEmpty && !query.isEmpty && !searching {45                    KAEmptyState(symbol: "house.slash", title: "Adresse introuvable",46                                 message: "Essayez numéro + rue + ville.")47                }48                ForEach(results) { r in49                    NavigationLink(value: r) {50                        VStack(alignment: .leading, spacing: 3) {51                            Text(r.adresse).font(.subheadline.weight(.semibold))52                            HStack {53                                Text(r.municipalite).font(.caption).foregroundStyle(.secondary)54                                if let t = r.type { KAChip(text: t, accent: universe.accent) }55                            }56                        }57                        .frame(maxWidth: .infinity, alignment: .leading)58                        .padding(12).kaCard(accent: universe.accent)59                    }60                    .buttonStyle(.plain)61                }62            }63            .padding(16)64        }65        .background(KATheme.paper(scheme))66        .navigationDestination(for: VPResult.self) { EstimateView(result: $0, universe: universe) }67    }6869    private func search() async {70        let q = query.trimmingCharacters(in: .whitespaces)71        guard q.count > 2 else { return }72        searching = true73        Haptics.rigid()74        defer { searching = false }75        guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/search") else { return }76        comps.queryItems = [.init(name: "q", value: q)]77        guard let url = comps.url, let root = try? await APIClient.shared.json(url),78              let arr = root.object?["results"]?.array else { results = []; return }79        results = arr.compactMap { v in80            guard let o = v.object, let id = o.str("id"), let ad = o.str("adresse") else { return nil }81            return VPResult(id: id, adresse: [ad, o.str("apt")].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: " app. "),82                            municipalite: o.str("municipalite") ?? "", type: o.str("typeProp"))83        }84        if !results.isEmpty { Haptics.success() }85    }86}8788// MARK: - Fiche d'estimation8990struct EstimateView: View {91    let result: VPResult92    let universe: Universe93    @State private var unit: [String: JSONValue]?94    @State private var res: [String: JSONValue]?95    @State private var failed = false96    @Environment(\.colorScheme) private var scheme9798    var body: some View {99        ScrollView {100            VStack(alignment: .leading, spacing: 16) {101                Text(result.adresse).font(.title2.weight(.bold))102                Text(result.municipalite).font(.subheadline).foregroundStyle(.secondary)103104                if let res {105                    estimateCard(res)106                    if let u = unit { historyCard(u); portraitCard(u) }107                    Link(destination: URL(string: "https://www.vrai-prix.com/estimation/\(result.id)")!) {108                        Label("Fiche complète + rapport PDF sur Vrai-Prix", systemImage: "doc.richtext")109                            .font(.headline)110                            .frame(maxWidth: .infinity).padding(.vertical, 14)111                            .background(universe.accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous))112                            .foregroundStyle(.white)113                    }114                } else if failed {115                    KAEmptyState(symbol: "wifi.exclamationmark", title: "Estimation indisponible",116                                 message: "Le service ne répond pas — réessayez.")117                } else {118                    ProgressView("Le moteur estime…").frame(maxWidth: .infinity).padding(40)119                }120            }121            .padding(16)122        }123        .background(KATheme.paper(scheme))124        .navigationBarTitleDisplayMode(.inline)125        .tint(universe.accent)126        .task { await load() }127    }128129    private func estimateCard(_ r: [String: JSONValue]) -> some View {130        VStack(alignment: .leading, spacing: 8) {131            Text("ESTIMATION VRAI-PRIX")132                .font(.system(size: 10, design: .monospaced).weight(.bold))133                .foregroundStyle(Color(hex: "#f5f3ee").opacity(0.6))134            Text((r.num("estimate") ?? 0).money0)135                .font(.system(size: 38, weight: .bold, design: .rounded))136                .foregroundStyle(universe.accent)137                .minimumScaleFactor(0.6).lineLimit(1)138            if let lo = r.num("low"), let hi = r.num("high") {139                Text("Fourchette \(lo.money0)\(hi.money0)")140                    .font(.subheadline).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.85))141            }142            HStack(spacing: 8) {143                if let level = r.str("confidenceLevel") {144                    Text("Confiance \(level)")145                        .font(.system(.caption, design: .monospaced).weight(.bold))146                        .padding(.horizontal, 10).padding(.vertical, 4)147                        .background(confColor(level), in: Capsule())148                        .foregroundStyle(.black)149                }150                if let pct = r.num("confidencePct") {151                    Text("\(Int(pct)) %").font(.caption).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.7))152                }153                Spacer()154                if let role = unit?.num("valeurRole") {155                    VStack(alignment: .trailing, spacing: 0) {156                        Text("Rôle 2026").font(.system(size: 9, design: .monospaced)).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.55))157                        Text(role.money0).font(.caption.weight(.bold)).foregroundStyle(Color(hex: "#f5f3ee"))158                    }159                }160            }161        }162        .padding(18)163        .frame(maxWidth: .infinity, alignment: .leading)164        .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 16, style: .continuous))165    }166167    private func historyCard(_ u: [String: JSONValue]) -> some View {168        Group {169            if let hist = u["history"]?.array, hist.count > 1 {170                VStack(alignment: .leading, spacing: 10) {171                    Text("Valeur au rôle, 2021 → 2026").font(.headline)172                    let pts: [(String, Double)] = hist.compactMap { h in173                        guard let o = h.object, let y = o.num("year"), let v = o.num("value") else { return nil }174                        return (String(Int(y)), v)175                    }176                    let maxV = pts.map(\.1).max() ?? 1177                    HStack(alignment: .bottom, spacing: 10) {178                        ForEach(pts, id: \.0) { (year, v) in179                            VStack(spacing: 4) {180                                Text(v.compact).font(.system(size: 8, design: .monospaced)).foregroundStyle(.secondary)181                                RoundedRectangle(cornerRadius: 3)182                                    .fill(universe.accent.opacity(year == pts.last?.0 ? 1 : 0.45))183                                    .frame(height: max(CGFloat(v / maxV) * 90, 6))184                                Text(year).font(.system(size: 9, design: .monospaced).weight(.bold))185                            }186                            .frame(maxWidth: .infinity)187                        }188                    }189                }190                .padding(14).kaCard(accent: universe.accent)191            }192        }193    }194195    private func portraitCard(_ u: [String: JSONValue]) -> some View {196        let rows: [(String, String?)] = [197            ("Type", u.str("cubfLibelle") ?? u.str("typeProp")),198            ("Année de construction", u.num("anneeConstruction").map { String(Int($0)) }),199            ("Aire des étages", u.num("aireEtagesM2").map { "\(Int($0)) m²" }),200            ("Terrain", u.num("superficieTerrainM2").map { "\(Int($0)) m²" }),201            ("Logements", u.num("nbLogements").map { String(Int($0)) }),202        ]203        return VStack(spacing: 0) {204            ForEach(rows.filter { $0.1 != nil }, id: \.0) { (label, value) in205                HStack {206                    Text(label).font(.subheadline).foregroundStyle(.secondary)207                    Spacer()208                    Text(value ?? "").font(.subheadline.weight(.semibold))209                }210                .padding(.vertical, 10).padding(.horizontal, 14)211                Divider().opacity(label == rows.last?.0 ? 0 : 1)212            }213        }214        .kaCard()215    }216217    private func confColor(_ level: String) -> Color {218        switch level {219        case "A": return Color(hex: "#d9f26b")220        case "B": return Color(hex: "#a8e063")221        case "C": return Color(hex: "#e8a33d")222        default: return Color(hex: "#ff8a80")223        }224    }225226    private func load() async {227        guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/estimate") else { return }228        comps.queryItems = [.init(name: "id", value: result.id)]229        guard let url = comps.url, let root = try? await APIClient.shared.json(url, ttl: 3600),230              let obj = root.object else { failed = true; return }231        unit = obj["unit"]?.object232        res = obj["result"]?.object233        if res == nil { failed = true } else { Haptics.success() }234    }235}236237// MARK: - Menu de resto (Resto·Ka)238239struct RestoMenuSection: Identifiable {240    let id = UUID()241    let name: String242    let items: [(name: String, price: String?)]243}244245enum RestoMenuLoader {246    static func load(uid: String) async -> [RestoMenuSection] {247        guard let url = URL(string: "https://www.resto-ka.com/api/restaurants/\(uid)"),248              let root = try? await APIClient.shared.json(url, ttl: 600),249              let menus = root.object?["menus"]?.array else { return [] }250        var sections: [RestoMenuSection] = []251        for menu in menus.prefix(1) {252            for s in menu.object?["sections"]?.array ?? [] {253                guard let so = s.object, let name = so.str("name") else { continue }254                let items: [(String, String?)] = (so["items"]?.array ?? []).prefix(30).compactMap { i in255                    guard let io = i.object, let n = io.str("name") else { return nil }256                    return (n, io.num("price").flatMap { $0 > 0 ? $0.money2 : nil })257                }258                if !items.isEmpty { sections.append(RestoMenuSection(name: name, items: items)) }259            }260        }261        return sections262    }263}264