SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

v1.0.0 (4) MAJOR — chaque univers ressemble à son site : filtres métier par univers (ville/taille/loyer Lou-Ka, prix Immo-Ka, marque/modèle/année/km Auto-Ka, cuisine/ville Resto-Ka, gratuit Sorti-Ka, en solde Food-Ka), héros d univers avec compteur live, fiches enrichies (salaires fourchette Job-Ka, prix régulier barré + unitaire Food-Ka, rouage/carrosserie Auto-Ka, superficies, dates/billets Sorti-Ka), Créa-Ka avec abonnés cumulés + liens de tous les comptes, MENU COMPLET des restos (sections + prix réels), et VRAI-PRIX NATIF (recherche d adresse → estimation, fourchette, confiance A-D, historique 2021-2026, portrait)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 063bd98

6 changed files +615 −14

modified KA/Core/Ecosystem.swift +51 −7
@@ -64,6 +64,8 @@ struct KAItem: Identifiable, Hashable, Codable {
64 64 var detail: String?
65 65 /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…)
66 66 var facts: [Fact]
67 + /// Liens riches (boutons) — ex. les comptes d'un créateur
68 + var links: [Fact] = []
67 69
68 70 struct Fact: Hashable, Codable { var label: String; var value: String }
69 71 }
@@ -151,6 +153,8 @@ enum Ecosystem {
151 153 o.str("unit_type").map { .init(label: "Taille", value: $0) },
152 154 o.str("address").map { .init(label: "Adresse", value: $0) },
153 155 o.str("available_by").map { .init(label: "Disponible", value: $0) },
156 + o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil },
157 + o.str("source").map { .init(label: "Gestionnaire", value: $0) },
154 158 ].compactMap { $0 }
155 159 return it
156 160 }),
@@ -166,7 +170,9 @@ enum Ecosystem {
166 170 it.facts = [
167 171 o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },
168 172 o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },
173 + o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil },
169 174 o.str("address").map { .init(label: "Adresse", value: $0) },
175 + o.str("source").map { .init(label: "Source", value: $0) },
170 176 ].compactMap { $0 }
171 177 return it
172 178 }),
@@ -189,6 +195,10 @@ enum Ecosystem {
189 195 o.str("model").map { .init(label: "Modèle", value: $0) },
190 196 o.str("transmission").map { .init(label: "Boîte", value: $0) },
191 197 o.str("fuel").map { .init(label: "Carburant", value: $0) },
198 + o.str("drivetrain").map { .init(label: "Rouage", value: $0) },
199 + o.str("body_type").map { .init(label: "Carrosserie", value: $0) },
200 + o.str("dealer_name").map { .init(label: "Concessionnaire", value: $0) },
201 + o.str("mileage_label").map { .init(label: "Kilométrage", value: $0) },
192 202 ].compactMap { $0 }
193 203 return it
194 204 }),
@@ -212,10 +222,16 @@ enum Ecosystem {
212 222 map: { o in
213 223 var it = base(o, universe: "food-ka")
214 224 it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ")
215 if let p = o.num("price"), p > 0.2 { it.priceLabel = p.money2 }
225 + let onSale: Bool = { if case .bool(true) = o["on_sale"] ?? .null { return true }; return false }()
226 + if let p = o.num("price"), p > 0.2 {
227 + if onSale, let reg = o.num("regular_price"), reg > p {
228 + it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))"
229 + } else { it.priceLabel = p.money2 }
230 + }
216 231 it.facts = [
217 232 o.str("category").map { .init(label: "Catégorie", value: $0) },
218 (o["on_sale"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Solde", value: "Oui 🏷️") } ; return nil }),
233 + o.num("unit_price").flatMap { $0 > 0.001 ? KAItem.Fact(label: "Prix unitaire", value: $0.money2) : nil },
234 + onSale ? KAItem.Fact(label: "Solde", value: "Oui 🏷️") : nil,
219 235 ].compactMap { $0 }
220 236 return it
221 237 }),
@@ -244,8 +260,11 @@ enum Ecosystem {
244 260 it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")
245 261 it.facts = [
246 262 o.str("start_date").map { .init(label: "Début", value: $0) },
263 + o.str("end_date").map { .init(label: "Fin", value: $0) },
247 264 o.str("venue").map { .init(label: "Lieu", value: $0) },
265 + o.str("region").map { .init(label: "Région", value: $0) },
248 266 (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }),
267 + o.num("price_min").flatMap { $0 > 1 ? KAItem.Fact(label: "Billets dès", value: $0.money0) : nil },
249 268 ].compactMap { $0 }
250 269 return it
251 270 }),
@@ -256,9 +275,20 @@ enum Ecosystem {
256 275 statTotalKeys: ["creators", "total"],
257 276 map: { o in
258 277 var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"])
259 it.subtitle = [o["niches"]?.text, o.str("primary_platform")].compactMap { $0 }.joined(separator: " · ")
260 if it.url == nil, let plats = o["platforms"]?.array?.first?.object {
261 it.url = plats.str("url").flatMap(URL.init(string:))
278 + let plats = o["platforms"]?.array?.compactMap(\.object) ?? []
279 + let totalFollowers = plats.compactMap { $0.num("followers") }.reduce(0, +)
280 + it.subtitle = [o["niches"]?.text,
281 + totalFollowers > 0 ? totalFollowers.compact + " abonnés" : nil]
282 + .compactMap { $0 }.joined(separator: " · ")
283 + if it.url == nil { it.url = plats.first?.str("url").flatMap(URL.init(string:)) }
284 + it.facts = plats.prefix(6).compactMap { pl in
285 + guard let name = pl.str("platform") else { return nil }
286 + let f = pl.num("followers").map { $0.compact } ?? ""
287 + return KAItem.Fact(label: name.capitalized, value: f.isEmpty ? "@" + (pl.str("handle") ?? "") : f + " abonnés")
288 + }
289 + it.links = plats.prefix(6).compactMap { pl in
290 + guard let name = pl.str("platform"), let url = pl.str("url") else { return nil }
291 + return KAItem.Fact(label: name.capitalized, value: url)
262 292 }
263 293 return it
264 294 }),
@@ -270,11 +300,18 @@ enum Ecosystem {
270 300 map: { o in
271 301 var it = base(o, universe: "job-ka")
272 302 it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ")
273 if let a = o.num("salary_year_min"), a > 20000 { it.priceLabel = a.money0 + "/an" }
303 + let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max")
304 + if let a = sMin, a > 20000 {
305 + it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)\(sMax!.money0)/an" : a.money0 + "/an"
306 + } else if let h = o.num("salary_hour_min"), h > 10 {
307 + it.priceLabel = h.money2 + "/h"
308 + }
274 309 it.facts = [
275 310 o.str("employer").map { .init(label: "Employeur", value: $0) },
276 o.str("work_mode").map { .init(label: "Mode", value: $0) },
311 + o.str("work_mode").map { .init(label: "Mode de travail", value: $0) },
277 312 o.str("employment_type").map { .init(label: "Type", value: $0) },
313 + o.str("ats").map { .init(label: "Plateforme carrière", value: $0) },
314 + o.str("date_posted").map { .init(label: "Publiée le", value: String($0.prefix(10))) },
278 315 ].compactMap { $0 }
279 316 return it
280 317 }),
@@ -301,6 +338,13 @@ enum Ecosystem {
301 338 // MARK: - petites extensions
302 339
303 340 extension Double {
341 + /// 17 100 000 → « 17,1 M », 5 200 → « 5,2 k »
342 + var compact: String {
343 + if self >= 1_000_000 { return String(format: "%.1f M", self / 1_000_000).replacingOccurrences(of: ".", with: ",") }
344 + if self >= 10_000 { return String(format: "%.0f k", self / 1_000) }
345 + if self >= 1_000 { return String(format: "%.1f k", self / 1_000).replacingOccurrences(of: ".", with: ",") }
346 + return String(Int(self))
347 + }
304 348 var money0: String {
305 349 let f = NumberFormatter(); f.numberStyle = .currency
306 350 f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0
added KA/Core/KAFilters.swift +201 −0
@@ -0,0 +1,201 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KAFilters.swift — les FILTRES MÉTIER de chaque univers, comme sur les sites
3 +// web : ville/taille/prix (Lou·Ka), marque/année/km (Auto·Ka), télétravail
4 +// (Job·Ka), gratuit (Sorti·Ka), en solde (Food·Ka), cuisine (Resto·Ka)…
5 +// Chaque filtre mappe directement un paramètre de l'API du site.
6 +import SwiftUI
7 +
8 +struct KAFilter: Identifiable {
9 + enum Kind {
10 + case text(placeholder: String) // champ libre → param
11 + case options([String]) // choix unique → param
12 + case minMax(minParam: String, maxParam: String, unit: String, step: Double, range: ClosedRange<Double>)
13 + case toggle(value: String) // interrupteur → param=value
14 + }
15 + let id: String // nom du paramètre API (ou préfixe pour minMax)
16 + let label: String
17 + let kind: Kind
18 +}
19 +
20 +enum FilterCatalog {
21 + static func filters(for universeID: String) -> [KAFilter] {
22 + switch universeID {
23 + case "lou-ka": return [
24 + .init(id: "city", label: "Ville", kind: .text(placeholder: "Québec, Montréal…")),
25 + .init(id: "unit_type", label: "Taille", kind: .options(["Studio", "1½", "2½", "3½", "4½", "5½", "6½"])),
26 + .init(id: "loyer", label: "Loyer", kind: .minMax(minParam: "price_min", maxParam: "price_max", unit: "$", step: 50, range: 300...4000)),
27 + ]
28 + case "immo-ka": return [
29 + .init(id: "city", label: "Ville", kind: .text(placeholder: "Lévis, Gatineau…")),
30 + .init(id: "prix", label: "Prix", kind: .minMax(minParam: "price_min", maxParam: "price_max", unit: "$", step: 25000, range: 50000...2000000)),
31 + ]
32 + case "auto-ka": return [
33 + .init(id: "make", label: "Marque", kind: .text(placeholder: "Toyota, Kia…")),
34 + .init(id: "model", label: "Modèle", kind: .text(placeholder: "Corolla…")),
35 + .init(id: "annee", label: "Année", kind: .minMax(minParam: "year_min", maxParam: "year_max", unit: "", step: 1, range: 2000...2026)),
36 + .init(id: "prix", label: "Prix", kind: .minMax(minParam: "price_min", maxParam: "price_max", unit: "$", step: 1000, range: 1000...120000)),
37 + .init(id: "km_max", label: "Km max", kind: .options(["50000", "100000", "150000", "200000"])),
38 + ]
39 + case "job-ka": return [
40 + .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal, Québec…")),
41 + ]
42 + case "food-ka": return [
43 + .init(id: "on_sale", label: "En solde 🏷️", kind: .toggle(value: "true")),
44 + ]
45 + case "resto-ka": return [
46 + .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal…")),
47 + .init(id: "cuisine", label: "Cuisine", kind: .options(["italien", "quebecois", "sushi", "bbq-grillades", "mexicain", "indien", "vegetarien", "dejeuner"])),
48 + ]
49 + case "sorti-ka": return [
50 + .init(id: "city", label: "Ville", kind: .text(placeholder: "Sherbrooke…")),
51 + .init(id: "region", label: "Région", kind: .options(["Montréal", "Capitale-Nationale", "Montérégie", "Estrie", "Laurentides", "Outaouais", "Mauricie"])),
52 + .init(id: "free", label: "Gratuit 🎉", kind: .toggle(value: "true")),
53 + ]
54 + default: return []
55 + }
56 + }
57 +}
58 +
59 +// MARK: - Barre + feuille de filtres
60 +
61 +struct FilterBar: View {
62 + let universe: Universe
63 + @Binding var params: [String: String]
64 + let onApply: () -> Void
65 + @State private var showSheet = false
66 + @Environment(\.colorScheme) private var scheme
67 +
68 + private var filters: [KAFilter] { FilterCatalog.filters(for: universe.id) }
69 + private var activeCount: Int { params.count }
70 +
71 + var body: some View {
72 + if !filters.isEmpty {
73 + ScrollView(.horizontal, showsIndicators: false) {
74 + HStack(spacing: 8) {
75 + Button {
76 + Haptics.tap(); showSheet = true
77 + } label: {
78 + Label(activeCount > 0 ? "Filtres · \(activeCount)" : "Filtres",
79 + systemImage: "line.3.horizontal.decrease.circle\(activeCount > 0 ? ".fill" : "")")
80 + .font(.system(.caption, design: .monospaced).weight(.bold))
81 + .padding(.horizontal, 12).padding(.vertical, 9)
82 + .background(activeCount > 0 ? universe.accent : KATheme.surface(scheme), in: Capsule())
83 + .foregroundStyle(activeCount > 0 ? .white : .primary)
84 + .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1))
85 + }
86 + // interrupteurs directement dans la barre
87 + ForEach(filters) { f in
88 + if case .toggle(let value) = f.kind {
89 + let on = params[f.id] == value
90 + Button {
91 + Haptics.tap()
92 + if on { params.removeValue(forKey: f.id) } else { params[f.id] = value }
93 + onApply()
94 + } label: {
95 + Text(f.label)
96 + .font(.system(.caption, design: .monospaced).weight(.bold))
97 + .padding(.horizontal, 12).padding(.vertical, 9)
98 + .background(on ? universe.accent : KATheme.surface(scheme), in: Capsule())
99 + .foregroundStyle(on ? .white : .primary)
100 + .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1))
101 + }
102 + }
103 + }
104 + if activeCount > 0 {
105 + Button {
106 + Haptics.tap(); params = [:]; onApply()
107 + } label: {
108 + Label("Effacer", systemImage: "xmark")
109 + .font(.system(.caption2, design: .monospaced).weight(.bold))
110 + .padding(.horizontal, 10).padding(.vertical, 9)
111 + .foregroundStyle(.secondary)
112 + }
113 + }
114 + }
115 + }
116 + .sheet(isPresented: $showSheet) {
117 + FilterSheet(universe: universe, params: $params) {
118 + showSheet = false
119 + onApply()
120 + }
121 + .presentationDetents([.medium, .large])
122 + }
123 + }
124 + }
125 +}
126 +
127 +struct FilterSheet: View {
128 + let universe: Universe
129 + @Binding var params: [String: String]
130 + let onApply: () -> Void
131 +
132 + var body: some View {
133 + NavigationStack {
134 + Form {
135 + ForEach(FilterCatalog.filters(for: universe.id)) { f in
136 + section(f)
137 + }
138 + }
139 + .navigationTitle("Filtres \(universe.name)")
140 + .navigationBarTitleDisplayMode(.inline)
141 + .toolbar {
142 + ToolbarItem(placement: .topBarLeading) {
143 + Button("Effacer") { params = [:] }.disabled(params.isEmpty)
144 + }
145 + ToolbarItem(placement: .topBarTrailing) {
146 + Button("Appliquer") { Haptics.success(); onApply() }
147 + .font(.headline).tint(universe.accent)
148 + }
149 + }
150 + }
151 + .tint(universe.accent)
152 + }
153 +
154 + @ViewBuilder
155 + private func section(_ f: KAFilter) -> some View {
156 + Section(f.label) {
157 + switch f.kind {
158 + case .text(let placeholder):
159 + TextField(placeholder, text: binding(f.id))
160 + .autocorrectionDisabled()
161 + case .options(let opts):
162 + Picker(f.label, selection: binding(f.id)) {
163 + Text("Tous").tag("")
164 + ForEach(opts, id: \.self) { Text($0).tag($0) }
165 + }
166 + .pickerStyle(.menu)
167 + case .minMax(let minP, let maxP, let unit, let step, let range):
168 + Stepper(value: numBinding(minP, default: range.lowerBound), in: range, step: step) {
169 + LabeledContent("Min", value: label(params[minP], unit: unit, fallback: "—"))
170 + }
171 + Stepper(value: numBinding(maxP, default: range.upperBound), in: range, step: step) {
172 + LabeledContent("Max", value: label(params[maxP], unit: unit, fallback: "—"))
173 + }
174 + if params[minP] != nil || params[maxP] != nil {
175 + Button("Réinitialiser \(f.label.lowercased())") {
176 + params.removeValue(forKey: minP); params.removeValue(forKey: maxP)
177 + }
178 + .font(.caption)
179 + }
180 + case .toggle(let value):
181 + Toggle(f.label, isOn: Binding(
182 + get: { params[f.id] == value },
183 + set: { params[f.id] = $0 ? value : nil; if !$0 { params.removeValue(forKey: f.id) } }
184 + ))
185 + }
186 + }
187 + }
188 +
189 + private func binding(_ key: String) -> Binding<String> {
190 + Binding(get: { params[key] ?? "" },
191 + set: { v in if v.isEmpty { params.removeValue(forKey: key) } else { params[key] = v } })
192 + }
193 + private func numBinding(_ key: String, default def: Double) -> Binding<Double> {
194 + Binding(get: { Double(params[key] ?? "") ?? def },
195 + set: { params[key] = String(Int($0)) })
196 + }
197 + private func label(_ raw: String?, unit: String, fallback: String) -> String {
198 + guard let raw, let n = Double(raw) else { return fallback }
199 + return unit == "$" ? n.money0 : "\(Int(n))\(unit.isEmpty ? "" : " \(unit)")"
200 + }
201 +}
modified KA/Core/Services.swift +6 −3
@@ -59,7 +59,7 @@ actor APIClient {
59 59 // MARK: - Univers : listes, recherche, stats
60 60
61 61 enum UniverseService {
62 static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30) -> URL? {
62 + static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) -> URL? {
63 63 guard let path = u.listPath else { return nil }
64 64 var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)!
65 65 // listPath peut contenir déjà une query (ex. events?upcoming=true)
@@ -72,13 +72,16 @@ enum UniverseService {
72 72 } : []
73 73 if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) }
74 74 if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) }
75 + for (k, v) in params.sorted(by: { $0.key < $1.key }) where !v.isEmpty {
76 + items.append(.init(name: k, value: v))
77 + }
75 78 items.append(.init(name: "limit", value: String(limit)))
76 79 comps.queryItems = items
77 80 return comps.url
78 81 }
79 82
80 static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30) async throws -> [KAItem] {
81 guard let url = listURL(u, query: query, city: city, limit: limit), let map = u.map else { return [] }
83 + static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] {
84 + guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] }
82 85 let root = try await APIClient.shared.json(url)
83 86 let obj = root.object ?? [:]
84 87 let raw = obj[u.itemsKey]?.array
modified KA/Features/UniversesView.swift +93 −3
@@ -102,11 +102,15 @@ struct UniverseHomeView: View {
102 102 @State private var query = ""
103 103 @State private var loading = true
104 104 @State private var errorText: String?
105 + @State private var filterParams: [String: String] = [:]
106 + @State private var liveTotal: Int?
105 107 @Environment(\.colorScheme) private var scheme
106 108
107 109 var body: some View {
108 110 Group {
109 if universe.listPath == nil {
111 + if universe.id == "vrai-prix" {
112 + VraiPrixView(universe: universe)
113 + } else if universe.listPath == nil {
110 114 webUniverse
111 115 } else {
112 116 list
@@ -142,9 +146,31 @@ struct UniverseHomeView: View {
142 146 .frame(maxWidth: .infinity, maxHeight: .infinity)
143 147 }
144 148
149 + /// En-tête façon héros de site : tagline + compteur live + filtres métier
150 + private var hero: some View {
151 + VStack(alignment: .leading, spacing: 10) {
152 + HStack(alignment: .firstTextBaseline) {
153 + Text(universe.tagline)
154 + .font(.system(.subheadline, design: .rounded).weight(.bold))
155 + Spacer()
156 + if let n = liveTotal {
157 + Text("\(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")
158 + .font(.system(.caption2, design: .monospaced).weight(.bold))
159 + .padding(.horizontal, 9).padding(.vertical, 4)
160 + .background(universe.accent.opacity(0.15), in: Capsule())
161 + .foregroundStyle(universe.accent)
162 + }
163 + }
164 + FilterBar(universe: universe, params: $filterParams) {
165 + Task { await load() }
166 + }
167 + }
168 + }
169 +
145 170 private var list: some View {
146 171 ScrollView {
147 172 LazyVStack(spacing: 12) {
173 + hero
148 174 if loading {
149 175 ProgressView().padding(40)
150 176 } else if let e = errorText, items.isEmpty {
@@ -166,14 +192,17 @@ struct UniverseHomeView: View {
166 192 .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")
167 193 .onSubmit(of: .search) { Task { await load() } }
168 194 .refreshable { await load() }
169 .task { await load() }
195 + .task {
196 + await load()
197 + liveTotal = await UniverseService.liveTotal(universe)
198 + }
170 199 }
171 200
172 201 private func load() async {
173 202 loading = items.isEmpty
174 203 errorText = nil
175 204 do {
176 items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40)
205 + items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40, params: filterParams)
177 206 } catch {
178 207 errorText = (error as? URLError)?.code == .notConnectedToInternet
179 208 ? "Vous êtes hors ligne." : "Le service ne répond pas."
@@ -189,6 +218,7 @@ struct ItemDetailView: View {
189 218 @EnvironmentObject private var favorites: FavoritesStore
190 219 @Environment(\.colorScheme) private var scheme
191 220 @State private var showCollections = false
221 + @State private var menu: [RestoMenuSection] = []
192 222
193 223 private var universe: Universe? { Ecosystem.universe(item.universeID) }
194 224
@@ -256,6 +286,60 @@ struct ItemDetailView: View {
256 286 .kaCard()
257 287 }
258 288
289 + if !item.links.isEmpty {
290 + VStack(alignment: .leading, spacing: 8) {
291 + Text("Comptes & liens").font(.headline)
292 + ForEach(item.links, id: \.self) { l in
293 + if let u = URL(string: l.value) {
294 + Link(destination: u) {
295 + HStack {
296 + Image(systemName: "link")
297 + Text(l.label).font(.subheadline.weight(.semibold))
298 + Spacer()
299 + Image(systemName: "arrow.up.right").font(.caption)
300 + }
301 + .padding(11)
302 + .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
303 + }
304 + .foregroundStyle(universe?.accent ?? .primary)
305 + }
306 + }
307 + }
308 + }
309 +
310 + if !menu.isEmpty {
311 + VStack(alignment: .leading, spacing: 10) {
312 + HStack {
313 + Text("Menu & prix réels").font(.headline)
314 + Spacer()
315 + KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)
316 + }
317 + ForEach(menu.prefix(6)) { section in
318 + DisclosureGroup {
319 + VStack(spacing: 0) {
320 + ForEach(section.items, id: \.name) { dish in
321 + HStack(alignment: .top) {
322 + Text(dish.name).font(.subheadline)
323 + Spacer()
324 + if let p = dish.price {
325 + Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))
326 + .foregroundStyle(universe?.accent ?? .primary)
327 + }
328 + }
329 + .padding(.vertical, 7)
330 + Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)
331 + }
332 + }
333 + .padding(.top, 4)
334 + } label: {
335 + Text(section.name).font(.subheadline.weight(.bold))
336 + }
337 + .padding(.horizontal, 14).padding(.vertical, 8)
338 + .kaCard()
339 + }
340 + }
341 + }
342 +
259 343 if let url = item.url {
260 344 Link(destination: url) {
261 345 Label("Voir à la source", systemImage: "arrow.up.right.square")
@@ -288,6 +372,12 @@ struct ItemDetailView: View {
288 372 }
289 373 }
290 374 }
375 + .task {
376 + if item.universeID == "resto-ka" {
377 + let uid = String(item.id.dropFirst("resto-ka:".count))
378 + menu = await RestoMenuLoader.load(uid: uid)
379 + }
380 + }
291 381 .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {
292 382 ForEach(favorites.collections) { c in
293 383 Button(c.name) { favorites.toggle(item, in: c.id) }
added KA/Features/VraiPrixView.swift +263 −0
@@ -0,0 +1,263 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// 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ète
4 +// (estimation, fourchette basse-haute, indice de confiance A-D, valeur au
5 +// rôle, historique 2021-2026 en barres, portrait de la propriété).
6 +import SwiftUI
7 +
8 +struct VPResult: Identifiable, Hashable {
9 + let id: String
10 + let adresse: String
11 + let municipalite: String
12 + let type: String?
13 +}
14 +
15 +struct VraiPrixView: View {
16 + let universe: Universe
17 + @State private var query = ""
18 + @State private var results: [VPResult] = []
19 + @State private var searching = false
20 + @Environment(\.colorScheme) private var scheme
21 +
22 + 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))
43 +
44 + 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 in
49 + 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 + }
68 +
69 + private func search() async {
70 + let q = query.trimmingCharacters(in: .whitespaces)
71 + guard q.count > 2 else { return }
72 + searching = true
73 + 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 in
80 + 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 +}
87 +
88 +// MARK: - Fiche d'estimation
89 +
90 +struct EstimateView: View {
91 + let result: VPResult
92 + let universe: Universe
93 + @State private var unit: [String: JSONValue]?
94 + @State private var res: [String: JSONValue]?
95 + @State private var failed = false
96 + @Environment(\.colorScheme) private var scheme
97 +
98 + 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)
103 +
104 + 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 + }
128 +
129 + 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 + }
166 +
167 + 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 in
173 + 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() ?? 1
177 + HStack(alignment: .bottom, spacing: 10) {
178 + ForEach(pts, id: \.0) { (year, v) in
179 + 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 + }
194 +
195 + 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) in
205 + 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 + }
216 +
217 + 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 + }
225 +
226 + 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"]?.object
232 + res = obj["result"]?.object
233 + if res == nil { failed = true } else { Haptics.success() }
234 + }
235 +}
236 +
237 +// MARK: - Menu de resto (Resto·Ka)
238 +
239 +struct RestoMenuSection: Identifiable {
240 + let id = UUID()
241 + let name: String
242 + let items: [(name: String, price: String?)]
243 +}
244 +
245 +enum 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 in
255 + 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 sections
262 + }
263 +}
modified project.yml +1 −1
@@ -10,7 +10,7 @@ settings:
10 10 base:
11 11 SWIFT_VERSION: "5.0"
12 12 MARKETING_VERSION: "1.0.0"
13 CURRENT_PROJECT_VERSION: "3"
13 + CURRENT_PROJECT_VERSION: "4"
14 14 DEVELOPMENT_TEAM: "3YM54G49SN"
15 15 CODE_SIGN_STYLE: Automatic
16 16 GENERATE_INFOPLIST_FILE: true
17 17