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>
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 | |