SPB Git

spb/ka-ios

Public

v3.1.0 — REFONTE MAJEURE : personnalisation locale (profil de préférences + feed « Pour vous » immersif), fiches nouvelle génération (héros photo plein cadre + carte de contenu + barre d'action flottante), Explorer en bandes éditoriales, recherche redessinée (manchette + tuiles d'idées par univers), agenda Sorti·Ka par horizon temporel, chips métier vérifiées live (fix cuisine=sushi→sushi-japonais + work_mode + category), hypothèque Immo·Ka, favoris filtres/tri/recherche, pouls en mosaïque, couches carte persistées + couche Favoris, section Personnalisation (Loi 25 : local, désactivable, effaçable), dock en espace réservé (plus jamais de contenu caché) — 27 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 26, 2026) parent 4972097 Browse files

17 changed files +1,525 −248

modified KA/App/KAApp.swift +21 −14
@@ -52,25 +52,32 @@ struct RootView: View {
52 _loaded = State(initialValue: [initial]) 52 _loaded = State(initialValue: [initial])
53 } 53 }
54 54
55+ /// Le dock s'efface dans les modes immersifs de Trajet
56+ /// (conduite et Découvrir plein écran)
57+ private var dockVisible: Bool { !trajet.navigating && !trajet.discovering }
58+ @Environment(\.colorScheme) private var scheme
59+
55 var body: some View { 60 var body: some View {
56− ZStack { 61+ // le dock occupe SON espace, sous le contenu : rien ne passe jamais
57− pane(0) { HomeView() } 62+ // dessous, sur aucune page — c'est une vraie barre, pas un flotteur
58− pane(1) { SearchView() } 63+ VStack(spacing: 0) {
59− pane(2) { UniversesView() } 64+ ZStack {
60− pane(3) { MapTab() } 65+ pane(0) { HomeView() }
61− pane(4) { TrajetView().environmentObject(trajet) } 66+ pane(1) { SearchView() }
62− pane(5) { MoreTab() } 67+ pane(2) { UniversesView() }
63− } 68+ pane(3) { MapTab() }
64− .tint(KATheme.green) 69+ pane(4) { TrajetView().environmentObject(trajet) }
65− .safeAreaInset(edge: .bottom) { 70+ pane(5) { MoreTab() }
66− // le dock s'efface dans les modes immersifs de Trajet 71+ }
67− // (conduite et Découvrir plein écran) 72+ if dockVisible {
68− if !trajet.navigating && !trajet.discovering {
69 KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true } 73 KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true }
70− .padding(.bottom, 4) 74+ .padding(.top, 8)
75+ .padding(.bottom, 2)
71 .transition(.move(edge: .bottom).combined(with: .opacity)) 76 .transition(.move(edge: .bottom).combined(with: .opacity))
72 } 77 }
73 } 78 }
79+ .background(KATheme.paper(scheme).ignoresSafeArea())
80+ .tint(KATheme.green)
74 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating) 81 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating)
75 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.discovering) 82 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.discovering)
76 .onChange(of: tab) { _, t in loaded.insert(t) } 83 .onChange(of: tab) { _, t in loaded.insert(t) }
modified KA/Core/Ecosystem.swift +12 −2
@@ -74,6 +74,11 @@ struct KAItem: Identifiable, Hashable, Codable {
74 var tags: [String]? = nil 74 var tags: [String]? = nil
75 /// Résumé « en bref » de la fiche (digest du site) 75 /// Résumé « en bref » de la fiche (digest du site)
76 var brief: String? = nil 76 var brief: String? = nil
77+ /// Prix NUMÉRIQUE (tri, gamme de prix du profil, hypothèque) — priceLabel
78+ /// reste l'affichage. Optionnel : les favoris sérialisés avant restent lisibles.
79+ var price: Double? = nil
80+ /// Date ISO « yyyy-MM-dd » (événements Sorti·Ka — regroupement temporel)
81+ var date: String? = nil
77 82
78 struct Fact: Hashable, Codable { var label: String; var value: String } 83 struct Fact: Hashable, Codable { var label: String; var value: String }
79 } 84 }
@@ -160,7 +165,7 @@ enum Ecosystem {
160 map: { o in 165 map: { o in
161 var it = base(o, universe: "lou-ka") 166 var it = base(o, universe: "lou-ka")
162 it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ") 167 it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ")
163− if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" } 168+ if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois"; it.price = p }
164 it.facts = [ 169 it.facts = [
165 o.str("unit_type").map { .init(label: "Taille", value: $0) }, 170 o.str("unit_type").map { .init(label: "Taille", value: $0) },
166 o.str("address").map { .init(label: "Adresse", value: $0) }, 171 o.str("address").map { .init(label: "Adresse", value: $0) },
@@ -178,7 +183,7 @@ enum Ecosystem {
178 map: { o in 183 map: { o in
179 var it = base(o, universe: "immo-ka") 184 var it = base(o, universe: "immo-ka")
180 it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ") 185 it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ")
181− if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 } 186+ if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0; it.price = p }
182 it.facts = [ 187 it.facts = [
183 o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) }, 188 o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },
184 o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) }, 189 o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },
@@ -202,6 +207,7 @@ enum Ecosystem {
202 var it = base(o, universe: "auto-ka") 207 var it = base(o, universe: "auto-ka")
203 it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ") 208 it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ")
204 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0) 209 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0)
210+ it.price = o.num("price")
205 it.facts = [ 211 it.facts = [
206 o.str("make").map { .init(label: "Marque", value: $0) }, 212 o.str("make").map { .init(label: "Marque", value: $0) },
207 o.str("model").map { .init(label: "Modèle", value: $0) }, 213 o.str("model").map { .init(label: "Modèle", value: $0) },
@@ -223,6 +229,7 @@ enum Ecosystem {
223 var it = base(o, universe: "fabri-ka") 229 var it = base(o, universe: "fabri-ka")
224 it.subtitle = o.str("store_id", "store") 230 it.subtitle = o.str("store_id", "store")
225 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2) 231 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2)
232+ it.price = o.num("price")
226 it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 } 233 it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 }
227 return it 234 return it
228 }), 235 }),
@@ -239,6 +246,7 @@ enum Ecosystem {
239 if onSale, let reg = o.num("regular_price"), reg > p { 246 if onSale, let reg = o.num("regular_price"), reg > p {
240 it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))" 247 it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))"
241 } else { it.priceLabel = p.money2 } 248 } else { it.priceLabel = p.money2 }
249+ it.price = p
242 } 250 }
243 it.facts = [ 251 it.facts = [
244 o.str("category").map { .init(label: "Catégorie", value: $0) }, 252 o.str("category").map { .init(label: "Catégorie", value: $0) },
@@ -269,6 +277,7 @@ enum Ecosystem {
269 statTotalKeys: ["total_active", "total"], 277 statTotalKeys: ["total_active", "total"],
270 map: { o in 278 map: { o in
271 var it = base(o, universe: "sorti-ka") 279 var it = base(o, universe: "sorti-ka")
280+ it.date = o.str("start_date")
272 it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ") 281 it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")
273 it.facts = [ 282 it.facts = [
274 o.str("start_date").map { .init(label: "Début", value: $0) }, 283 o.str("start_date").map { .init(label: "Début", value: $0) },
@@ -315,6 +324,7 @@ enum Ecosystem {
315 let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max") 324 let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max")
316 if let a = sMin, a > 20000 { 325 if let a = sMin, a > 20000 {
317 it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an" 326 it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an"
327+ it.price = a
318 } else if let h = o.num("salary_hour_min"), h > 10 { 328 } else if let h = o.num("salary_hour_min"), h > 10 {
319 it.priceLabel = h.money2 + "/h" 329 it.priceLabel = h.money2 + "/h"
320 } 330 }
modified KA/Core/FavoritesStore.swift +2 −0
@@ -54,9 +54,11 @@ final class FavoritesStore: ObservableObject {
54 for i in collections.indices { 54 for i in collections.indices {
55 collections[i].items.removeAll { $0.id == item.id } 55 collections[i].items.removeAll { $0.id == item.id }
56 } 56 }
57+ PersonalizationStore.shared.record(.unfavorite, item: item)
57 } else { 58 } else {
58 let idx = collections.firstIndex { $0.id == collectionID } ?? 0 59 let idx = collections.firstIndex { $0.id == collectionID } ?? 0
59 collections[idx].items.insert(item, at: 0) 60 collections[idx].items.insert(item, at: 0)
61+ PersonalizationStore.shared.record(.favorite, item: item)
60 } 62 }
61 Haptics.tap() 63 Haptics.tap()
62 } 64 }
modified KA/Core/KAFilters.swift +107 −1
@@ -38,17 +38,20 @@ enum FilterCatalog {
38 ] 38 ]
39 case "job-ka": return [ 39 case "job-ka": return [
40 .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal, Québec…")), 40 .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal, Québec…")),
41+ .init(id: "work_mode", label: "Mode de travail", kind: .options(["teletravail", "hybride", "presentiel"])),
41 ] 42 ]
42 case "food-ka": return [ 43 case "food-ka": return [
43 .init(id: "on_sale", label: "En solde 🏷️", kind: .toggle(value: "true")), 44 .init(id: "on_sale", label: "En solde 🏷️", kind: .toggle(value: "true")),
44 ] 45 ]
45 case "resto-ka": return [ 46 case "resto-ka": return [
46 .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal…")), 47 .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+ // slugs RÉELS de l'API (cuisine=sushi retournait 0 — vérifié live 2026-08-26)
49+ .init(id: "cuisine", label: "Cuisine", kind: .options(["italien", "quebecois", "sushi-japonais", "pizza", "burgers", "bbq-grillades", "mexicain", "indien", "chinois", "fruits-de-mer", "vegetarien-vegan", "dejeuner-brunch", "cafe-dessert", "poulet"])),
48 ] 50 ]
49 case "sorti-ka": return [ 51 case "sorti-ka": return [
50 .init(id: "city", label: "Ville", kind: .text(placeholder: "Sherbrooke…")), 52 .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"])), 53 .init(id: "region", label: "Région", kind: .options(["Montréal", "Capitale-Nationale", "Montérégie", "Estrie", "Laurentides", "Outaouais", "Mauricie"])),
54+ .init(id: "category", label: "Catégorie", kind: .options(["musique", "festival", "famille", "sport", "exposition-musee", "arts-scene", "plein-air", "cinema"])),
52 .init(id: "free", label: "Gratuit 🎉", kind: .toggle(value: "true")), 55 .init(id: "free", label: "Gratuit 🎉", kind: .toggle(value: "true")),
53 ] 56 ]
54 default: return [] 57 default: return []
@@ -56,6 +59,109 @@ enum FilterCatalog {
56 } 59 }
57 } 60 }
58 61
62+// MARK: - Chips métier rapides — l'ADN de chaque univers, en un tap
63+// (paramètres et valeurs VÉRIFIÉS live sur les API le 2026-08-26)
64+
65+struct QuickFilter: Identifiable, Hashable {
66+ let label: String
67+ let params: [String: String]
68+ var id: String { label }
69+}
70+
71+enum QuickFilterCatalog {
72+ static func chips(for universeID: String) -> [QuickFilter] {
73+ switch universeID {
74+ case "lou-ka": return [
75+ .init(label: "3½", params: ["unit_type": "3½"]),
76+ .init(label: "4½", params: ["unit_type": "4½"]),
77+ .init(label: "5½", params: ["unit_type": "5½"]),
78+ .init(label: "≤ 1 000 $", params: ["price_max": "1000"]),
79+ .init(label: "≤ 1 500 $", params: ["price_max": "1500"]),
80+ ]
81+ case "immo-ka": return [
82+ .init(label: "≤ 300 k$", params: ["price_max": "300000"]),
83+ .init(label: "300–500 k$", params: ["price_min": "300000", "price_max": "500000"]),
84+ .init(label: "500 k$ +", params: ["price_min": "500000"]),
85+ ]
86+ case "auto-ka": return [
87+ .init(label: "Toyota", params: ["make": "Toyota"]),
88+ .init(label: "Honda", params: ["make": "Honda"]),
89+ .init(label: "Hyundai", params: ["make": "Hyundai"]),
90+ .init(label: "Mazda", params: ["make": "Mazda"]),
91+ .init(label: "≤ 15 000 $", params: ["price_max": "15000"]),
92+ ]
93+ case "resto-ka": return [
94+ .init(label: "Italien", params: ["cuisine": "italien"]),
95+ .init(label: "Sushi", params: ["cuisine": "sushi-japonais"]),
96+ .init(label: "Pizza", params: ["cuisine": "pizza"]),
97+ .init(label: "Québécois", params: ["cuisine": "quebecois"]),
98+ .init(label: "Brunch", params: ["cuisine": "dejeuner-brunch"]),
99+ .init(label: "Végé", params: ["cuisine": "vegetarien-vegan"]),
100+ ]
101+ case "sorti-ka": return [
102+ .init(label: "Musique", params: ["category": "musique"]),
103+ .init(label: "Festival", params: ["category": "festival"]),
104+ .init(label: "Famille", params: ["category": "famille"]),
105+ .init(label: "Sport", params: ["category": "sport"]),
106+ .init(label: "Expos", params: ["category": "exposition-musee"]),
107+ ]
108+ case "job-ka": return [
109+ .init(label: "Télétravail", params: ["work_mode": "teletravail"]),
110+ .init(label: "Hybride", params: ["work_mode": "hybride"]),
111+ .init(label: "Présentiel", params: ["work_mode": "presentiel"]),
112+ ]
113+ default: return []
114+ }
115+ }
116+}
117+
118+/// Rangée de chips métier (sélection exclusive, fusionnée aux params API)
119+struct UniverseQuickChips: View {
120+ let universe: Universe
121+ @Binding var params: [String: String]
122+ let onApply: () -> Void
123+ @Environment(\.colorScheme) private var scheme
124+
125+ private var chips: [QuickFilter] { QuickFilterCatalog.chips(for: universe.id) }
126+
127+ private func isOn(_ chip: QuickFilter) -> Bool {
128+ chip.params.allSatisfy { params[$0.key] == $0.value }
129+ }
130+
131+ var body: some View {
132+ if !chips.isEmpty {
133+ ScrollView(.horizontal, showsIndicators: false) {
134+ HStack(spacing: 8) {
135+ ForEach(chips) { chip in
136+ let on = isOn(chip)
137+ Button {
138+ Haptics.tap()
139+ // exclusif au sein du catalogue : on retire les
140+ // clés des autres chips avant d'appliquer
141+ for other in chips {
142+ for k in other.params.keys { params.removeValue(forKey: k) }
143+ }
144+ if !on { params.merge(chip.params) { _, new in new } }
145+ onApply()
146+ } label: {
147+ Text(chip.label)
148+ .font(.system(.caption, design: .rounded, weight: .bold))
149+ .padding(.horizontal, 12).padding(.vertical, 8)
150+ .background(on ? universe.accent : KATheme.surface(scheme), in: Capsule())
151+ .foregroundStyle(on ? .white : KATheme.ink(scheme))
152+ .overlay(Capsule().strokeBorder(
153+ on ? universe.accent : KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))
154+ }
155+ .accessibilityLabel("\(on ? "Retirer le filtre" : "Filtrer :") \(chip.label)")
156+ .accessibilityAddTraits(on ? [.isSelected] : [])
157+ }
158+ }
159+ .padding(.vertical, 2)
160+ }
161+ }
162+ }
163+}
164+
59 // MARK: - Barre + feuille de filtres 165 // MARK: - Barre + feuille de filtres
60 166
61 struct FilterBar: View { 167 struct FilterBar: View {
added KA/Core/Personalization.swift +191 −0
@@ -0,0 +1,191 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Personalization.swift — le cerveau local de la super-app : chaque geste
3+// (fiche ouverte, favori, recherche, lien source) devient un signal, agrégé
4+// avec décroissance temporelle en un PROFIL DE PRÉFÉRENCES (univers suivis,
5+// villes, gamme de prix, mots-clés) qui alimente le feed « Pour vous », la
6+// recherche et l'accueil. 100 % sur l'appareil — rien n'est envoyé au serveur,
7+// désactivable et effaçable dans Profil (esprit Loi 25).
8+import Foundation
9+import SwiftUI
10+
11+// MARK: - Signaux
12+
13+enum InteractionKind: String, Codable {
14+ case view // fiche consultée
15+ case favorite // ajout aux favoris (signal fort)
16+ case unfavorite // retrait (signal négatif)
17+ case search // recherche soumise
18+ case openSource // sortie vers l'annonce originale (signal très fort)
19+}
20+
21+struct InteractionEvent: Codable {
22+ var kind: InteractionKind
23+ var universeID: String?
24+ var city: String?
25+ var price: Double?
26+ var tags: [String] = []
27+ var query: String?
28+ var date: Date = .now
29+}
30+
31+// MARK: - Profil de préférences (calcul pur, testé dans KATests)
32+
33+struct PreferenceProfile {
34+ /// Affinité par univers, normalisée 0…1
35+ var affinity: [String: Double] = [:]
36+ /// Villes les plus consultées, de la plus forte à la plus faible
37+ var topCities: [String] = []
38+ /// Gamme de prix consultée par univers (autour de la médiane pondérée)
39+ var priceBands: [String: ClosedRange<Double>] = [:]
40+ /// Mots-clés récurrents (étiquettes de fiches + termes de recherche)
41+ var topTags: [String] = []
42+ var eventCount: Int = 0
43+
44+ /// Assez de signal pour personnaliser sans dire n'importe quoi
45+ var hasSignals: Bool { eventCount >= 5 && !affinity.isEmpty }
46+
47+ func topUniverses(_ n: Int) -> [String] {
48+ affinity.sorted { $0.value > $1.value }.prefix(n).map(\.key)
49+ }
50+}
51+
52+enum PreferenceEngine {
53+ /// Poids d'un geste (le favori et la sortie source pèsent plus qu'une vue)
54+ static func weight(_ kind: InteractionKind) -> Double {
55+ switch kind {
56+ case .view: return 1.0
57+ case .favorite: return 3.0
58+ case .unfavorite: return -3.0 // le retrait annule complètement le favori
59+ case .search: return 1.5
60+ case .openSource: return 2.0
61+ }
62+ }
63+
64+ /// Décroissance : un geste d'il y a 2 semaines pèse ~37 % d'un geste du jour
65+ static func decay(_ date: Date, now: Date) -> Double {
66+ let days = max(0, now.timeIntervalSince(date) / 86_400)
67+ return exp(-days / 14)
68+ }
69+
70+ static func computeProfile(_ events: [InteractionEvent], now: Date = .now) -> PreferenceProfile {
71+ var p = PreferenceProfile()
72+ p.eventCount = events.count
73+ var uniScore: [String: Double] = [:]
74+ var cityScore: [String: Double] = [:]
75+ var tagScore: [String: Double] = [:]
76+ var prices: [String: [(Double, Double)]] = [:] // universe → (prix, poids)
77+
78+ for e in events {
79+ let w = weight(e.kind) * decay(e.date, now: now)
80+ if let u = e.universeID { uniScore[u, default: 0] += w }
81+ if let c = e.city, !c.isEmpty, w > 0 { cityScore[c, default: 0] += w }
82+ for t in e.tags where t.count > 2 && w > 0 {
83+ tagScore[t.lowercased(), default: 0] += w
84+ }
85+ if let q = e.query {
86+ for word in q.lowercased().split(separator: " ") where word.count > 3 {
87+ tagScore[String(word), default: 0] += w * 0.6
88+ }
89+ }
90+ if let u = e.universeID, let pr = e.price, pr > 0, w > 0 {
91+ prices[u, default: []].append((pr, w))
92+ }
93+ }
94+
95+ if let maxU = uniScore.values.max(), maxU > 0 {
96+ p.affinity = uniScore.filter { $0.value > 0 }.mapValues { $0 / maxU }
97+ }
98+ p.topCities = cityScore.sorted { $0.value > $1.value }.prefix(5).map(\.key)
99+ p.topTags = tagScore.sorted { $0.value > $1.value }.prefix(12).map(\.key)
100+ for (u, list) in prices {
101+ let sorted = list.sorted { $0.0 < $1.0 }
102+ // médiane pondérée : le prix au milieu de la masse des poids
103+ let totalW = sorted.reduce(0) { $0 + $1.1 }
104+ var acc = 0.0
105+ var median = sorted[sorted.count / 2].0
106+ for (price, w) in sorted {
107+ acc += w
108+ if acc >= totalW / 2 { median = price; break }
109+ }
110+ p.priceBands[u] = (median * 0.6)...(median * 1.45)
111+ }
112+ return p
113+ }
114+
115+ /// Score de recommandation d'un item pour ce profil (plus haut = mieux placé).
116+ static func score(_ item: KAItem, profile: PreferenceProfile) -> Double {
117+ var s = profile.affinity[item.universeID] ?? 0
118+ if let c = item.city, profile.topCities.prefix(3).contains(c) { s += 0.35 }
119+ if let pr = item.price, let band = profile.priceBands[item.universeID],
120+ band.contains(pr) { s += 0.25 }
121+ let haystack = (item.title + " " + (item.subtitle ?? "")).lowercased()
122+ var tagBonus = 0.0
123+ for t in profile.topTags where haystack.contains(t) {
124+ tagBonus += 0.1
125+ if tagBonus >= 0.3 { break }
126+ }
127+ return s + tagBonus
128+ }
129+}
130+
131+// MARK: - Le magasin de signaux (persistance locale, plafonné)
132+
133+@MainActor
134+final class PersonalizationStore: ObservableObject {
135+ static let shared = PersonalizationStore()
136+ static let enabledKey = "ka.perso.enabled"
137+ private static let maxEvents = 600
138+
139+ @Published private(set) var events: [InteractionEvent] = []
140+ @Published private(set) var profile = PreferenceProfile()
141+
142+ /// Personnalisation active (défaut : oui) — pilotée depuis Profil.
143+ var enabled: Bool {
144+ UserDefaults.standard.object(forKey: Self.enabledKey) == nil
145+ ? true : UserDefaults.standard.bool(forKey: Self.enabledKey)
146+ }
147+
148+ private var fileURL: URL {
149+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
150+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
151+ return dir.appendingPathComponent("ka-signaux.json")
152+ }
153+
154+ init() {
155+ if let data = try? Data(contentsOf: fileURL),
156+ let saved = try? JSONDecoder().decode([InteractionEvent].self, from: data) {
157+ events = saved
158+ }
159+ profile = PreferenceEngine.computeProfile(events)
160+ }
161+
162+ func record(_ kind: InteractionKind, item: KAItem) {
163+ record(InteractionEvent(kind: kind, universeID: item.universeID,
164+ city: item.city, price: item.price,
165+ tags: Array((item.tags ?? []).prefix(6))))
166+ }
167+
168+ func recordSearch(_ query: String) {
169+ record(InteractionEvent(kind: .search, universeID: nil, query: query))
170+ }
171+
172+ func record(_ event: InteractionEvent) {
173+ guard enabled else { return }
174+ events.append(event)
175+ if events.count > Self.maxEvents { events.removeFirst(events.count - Self.maxEvents) }
176+ profile = PreferenceEngine.computeProfile(events)
177+ save()
178+ }
179+
180+ func reset() {
181+ events = []
182+ profile = PreferenceProfile()
183+ try? FileManager.default.removeItem(at: fileURL)
184+ }
185+
186+ private func save() {
187+ if let data = try? JSONEncoder().encode(events) {
188+ try? data.write(to: fileURL, options: .atomic)
189+ }
190+ }
191+}
added KA/Core/Recommendation.swift +144 −0
@@ -0,0 +1,144 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Recommendation.swift — le feed « Pour vous » : à partir du profil de
3+// préférences (Personalization.swift), on interroge les univers les plus
4+// suivis avec la ville dominante, on SCORE chaque item côté client (ville,
5+// gamme de prix, mots-clés) puis on entrelace les univers — jamais un simple
6+// tri chronologique, jamais de contenu inventé : tout vient des API live.
7+// Prêt pour mieux plus tard (embeddings, collaboratif) : il suffit de
8+// remplacer PreferenceEngine.score sans toucher aux vues.
9+import Foundation
10+
11+struct ForYouEntry: Identifiable, Hashable {
12+ let item: KAItem
13+ let reason: String
14+ var id: String { item.id }
15+}
16+
17+enum RecommendationService {
18+ /// Univers où filtrer par ville a du sens (épicerie/produits/créateurs : non)
19+ private static let citySensible: Set<String> = [
20+ "lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka", "auto-ka",
21+ ]
22+
23+ /// Construit le feed mélangé et classé. `excluding` = déjà vus (historique).
24+ static func feed(profile: PreferenceProfile,
25+ excluding: Set<String> = [],
26+ limit: Int = 10) async -> [ForYouEntry] {
27+ guard profile.hasSignals else { return [] }
28+ let universes = profile.topUniverses(4)
29+ .compactMap(Ecosystem.universe)
30+ .filter { $0.map != nil }
31+ guard !universes.isEmpty else { return [] }
32+ let city = profile.topCities.first
33+
34+ var pools: [String: [KAItem]] = [:]
35+ await withTaskGroup(of: (String, [KAItem]).self) { group in
36+ for u in universes {
37+ group.addTask {
38+ let c = citySensible.contains(u.id) ? city : nil
39+ var items = (try? await UniverseService.fetch(u, city: c, limit: 14)) ?? []
40+ // ville trop pointue pour cet univers ? on élargit
41+ if items.isEmpty, c != nil {
42+ items = (try? await UniverseService.fetch(u, limit: 14)) ?? []
43+ }
44+ return (u.id, items)
45+ }
46+ }
47+ for await (id, items) in group { pools[id] = items }
48+ }
49+
50+ // score + tri à l'intérieur de chaque univers
51+ var ranked: [String: [KAItem]] = [:]
52+ for (id, items) in pools {
53+ ranked[id] = items
54+ .filter { !excluding.contains($0.id) }
55+ .sorted { PreferenceEngine.score($0, profile: profile) > PreferenceEngine.score($1, profile: profile) }
56+ }
57+
58+ // entrelacement pondéré : l'univers dominant place plus de cartes,
59+ // mais le feed reste un MÉLANGE (jamais 10 cartes du même univers)
60+ var feed: [ForYouEntry] = []
61+ var cursor: [String: Int] = [:]
62+ let order = universes.map(\.id)
63+ while feed.count < limit {
64+ var advanced = false
65+ for id in order {
66+ let take = (profile.affinity[id] ?? 0) > 0.66 ? 2 : 1
67+ for _ in 0..<take where feed.count < limit {
68+ let i = cursor[id, default: 0]
69+ guard let pool = ranked[id], i < pool.count else { continue }
70+ let item = pool[i]
71+ cursor[id] = i + 1
72+ feed.append(ForYouEntry(item: item, reason: reason(item, profile: profile)))
73+ advanced = true
74+ }
75+ }
76+ if !advanced { break }
77+ }
78+ return feed
79+ }
80+
81+ /// Pourquoi cette carte est là — honnête et lisible.
82+ static func reason(_ item: KAItem, profile: PreferenceProfile) -> String {
83+ if let c = item.city, profile.topCities.prefix(3).contains(c) {
84+ return "À \(c), comme vos dernières visites"
85+ }
86+ if let p = item.price, let band = profile.priceBands[item.universeID], band.contains(p) {
87+ return "Dans votre gamme de prix"
88+ }
89+ if let u = Ecosystem.universe(item.universeID) {
90+ return "Parce que vous suivez \(u.wordmark)"
91+ }
92+ return "Suggestion de l'écosystème"
93+ }
94+}
95+
96+// MARK: - Regroupement temporel des événements (Sorti·Ka)
97+
98+enum EventBucket: Int, CaseIterable, Comparable {
99+ case today, thisWeek, thisMonth, later, ongoing, undated
100+
101+ var label: String {
102+ switch self {
103+ case .today: return "Aujourd'hui"
104+ case .thisWeek: return "Cette semaine"
105+ case .thisMonth: return "Ce mois-ci"
106+ case .later: return "Plus tard"
107+ case .ongoing: return "En ce moment"
108+ case .undated: return "À venir"
109+ }
110+ }
111+
112+ static func < (a: EventBucket, b: EventBucket) -> Bool { a.rawValue < b.rawValue }
113+
114+ /// Classe une date ISO « yyyy-MM-dd » par rapport à aujourd'hui.
115+ static func bucket(iso: String?, now: Date = .now) -> EventBucket {
116+ guard let iso, iso.count >= 10 else { return .undated }
117+ let fmt = DateFormatter()
118+ fmt.dateFormat = "yyyy-MM-dd"
119+ fmt.locale = Locale(identifier: "fr_CA")
120+ guard let date = fmt.date(from: String(iso.prefix(10))) else { return .undated }
121+ var cal = Calendar(identifier: .gregorian)
122+ cal.locale = Locale(identifier: "fr_CA")
123+ if cal.isDate(date, inSameDayAs: now) { return .today }
124+ if date < now { return .ongoing } // commencé, toujours actif (upcoming=true)
125+ if let week = cal.date(byAdding: .day, value: 7, to: now), date <= week { return .thisWeek }
126+ if let month = cal.date(byAdding: .day, value: 31, to: now), date <= month { return .thisMonth }
127+ return .later
128+ }
129+}
130+
131+// MARK: - Hypothèque (Immo·Ka) — calcul pur, testé
132+
133+enum MortgageMath {
134+ /// Paiement mensuel : prix, mise de fonds (fraction 0…1), taux annuel (%), années.
135+ static func monthlyPayment(price: Double, downFraction: Double,
136+ annualRate: Double, years: Int) -> Double {
137+ let principal = price * (1 - downFraction)
138+ guard principal > 0, years > 0 else { return 0 }
139+ let r = annualRate / 100 / 12
140+ let n = Double(years * 12)
141+ guard r > 0 else { return principal / n }
142+ return principal * r * pow(1 + r, n) / (pow(1 + r, n) - 1)
143+ }
144+}
modified KA/Features/FavoritesView.swift +139 −60
@@ -1,89 +1,168 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 // FavoritesView.swift — favoris & collections unifiés (multi-univers dans une 2 // FavoritesView.swift — favoris & collections unifiés (multi-univers dans une
3−// même collection), consultables hors ligne, partage natif. 3+// même collection), consultables hors ligne, avec FILTRE par univers, TRI
4+// (récent / prix) et RECHERCHE dans ses trouvailles.
4 import SwiftUI 5 import SwiftUI
5 6
6 struct FavoritesView: View { 7 struct FavoritesView: View {
7 @EnvironmentObject private var favorites: FavoritesStore 8 @EnvironmentObject private var favorites: FavoritesStore
8 @State private var newName = "" 9 @State private var newName = ""
9 @State private var showNew = false 10 @State private var showNew = false
11+ @State private var filterUniverse: String?
12+ @State private var sort: FavSort = .recent
13+ @State private var searchText = ""
10 @Environment(\.colorScheme) private var scheme 14 @Environment(\.colorScheme) private var scheme
11 15
16+ enum FavSort: String, CaseIterable {
17+ case recent = "Récents"
18+ case prixAsc = "Prix ↑"
19+ case prixDesc = "Prix ↓"
20+ case titre = "A → Z"
21+ }
22+
23+ /// Univers réellement présents dans les favoris (chips de filtre)
24+ private var presentUniverses: [Universe] {
25+ let ids = Set(favorites.allItems.map(\.universeID))
26+ return Ecosystem.all.filter { ids.contains($0.id) }
27+ }
28+
29+ private func filtered(_ items: [KAItem]) -> [KAItem] {
30+ var out = items
31+ if let f = filterUniverse { out = out.filter { $0.universeID == f } }
32+ if !searchText.isEmpty {
33+ out = out.filter {
34+ $0.title.localizedCaseInsensitiveContains(searchText)
35+ || ($0.city ?? "").localizedCaseInsensitiveContains(searchText)
36+ || ($0.subtitle ?? "").localizedCaseInsensitiveContains(searchText)
37+ }
38+ }
39+ switch sort {
40+ case .recent: return out
41+ case .prixAsc: return out.sorted { ($0.price ?? .greatestFiniteMagnitude) < ($1.price ?? .greatestFiniteMagnitude) }
42+ case .prixDesc: return out.sorted { ($0.price ?? 0) > ($1.price ?? 0) }
43+ case .titre: return out.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
44+ }
45+ }
46+
12 var body: some View { 47 var body: some View {
13− NavigationStack { 48+ ScrollView {
14− ScrollView { 49+ VStack(alignment: .leading, spacing: 18) {
15− VStack(alignment: .leading, spacing: 18) { 50+ if favorites.allItems.isEmpty {
16− if favorites.allItems.isEmpty { 51+ KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant",
17− KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant", 52+ message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.")
18− message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.") 53+ } else {
19− } 54+ universeChips
20− ForEach(favorites.collections) { collection in 55+ }
21− collectionSection(collection) 56+ ForEach(favorites.collections) { collection in
22− } 57+ collectionSection(collection)
23 } 58 }
24− .padding(16)
25 } 59 }
26− .background(KATheme.paper(scheme)) 60+ .padding(16)
27− .navigationTitle("Favoris") 61+ }
28− .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } 62+ .background(KATheme.paper(scheme))
29− .toolbar { 63+ .navigationTitle("Favoris")
30− ToolbarItem(placement: .topBarTrailing) { 64+ .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
31− Button { showNew = true } label: { Image(systemName: "folder.badge.plus") } 65+ .searchable(text: $searchText, prompt: "Chercher dans vos favoris…")
32− .accessibilityLabel("Nouvelle collection") 66+ .toolbar {
67+ ToolbarItemGroup(placement: .topBarTrailing) {
68+ if favorites.allItems.count > 1 {
69+ Menu {
70+ Picker("Tri", selection: $sort) {
71+ ForEach(FavSort.allCases, id: \.self) { Text($0.rawValue).tag($0) }
72+ }
73+ } label: { Image(systemName: "arrow.up.arrow.down") }
74+ .accessibilityLabel("Trier les favoris")
33 } 75 }
76+ Button { showNew = true } label: { Image(systemName: "folder.badge.plus") }
77+ .accessibilityLabel("Nouvelle collection")
78+ }
79+ }
80+ .alert("Nouvelle collection", isPresented: $showNew) {
81+ TextField("Ex. Déménagement à Val-d'Or", text: $newName)
82+ Button("Créer") {
83+ favorites.addCollection(newName)
84+ newName = ""
85+ Haptics.success()
34 } 86 }
35− .alert("Nouvelle collection", isPresented: $showNew) { 87+ Button("Annuler", role: .cancel) { newName = "" }
36− TextField("Ex. Déménagement à Val-d'Or", text: $newName) 88+ } message: {
37− Button("Créer") { 89+ Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")
38− favorites.addCollection(newName) 90+ }
39− newName = "" 91+ }
40− Haptics.success() 92+
93+ private var universeChips: some View {
94+ ScrollView(.horizontal, showsIndicators: false) {
95+ HStack(spacing: 8) {
96+ chip(label: "Tous · \(favorites.allItems.count)", accent: KATheme.green,
97+ on: filterUniverse == nil) { filterUniverse = nil }
98+ ForEach(presentUniverses) { u in
99+ let count = favorites.allItems.filter { $0.universeID == u.id }.count
100+ chip(label: "\(u.wordmark) · \(count)", accent: u.accent,
101+ on: filterUniverse == u.id) {
102+ filterUniverse = filterUniverse == u.id ? nil : u.id
103+ }
41 } 104 }
42− Button("Annuler", role: .cancel) { newName = "" }
43− } message: {
44− Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")
45 } 105 }
46 } 106 }
47 } 107 }
48 108
109+ private func chip(label: String, accent: Color, on: Bool, action: @escaping () -> Void) -> some View {
110+ Button {
111+ Haptics.tap()
112+ withAnimation(.snappy) { action() }
113+ } label: {
114+ Text(label)
115+ .font(.system(.caption, design: .monospaced).weight(.bold))
116+ .padding(.horizontal, 11).padding(.vertical, 7)
117+ .background(on ? accent : KATheme.surface(scheme), in: Capsule())
118+ .foregroundStyle(on ? .white : .secondary)
119+ .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
120+ }
121+ .accessibilityAddTraits(on ? [.isSelected] : [])
122+ }
123+
49 @ViewBuilder 124 @ViewBuilder
50 private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View { 125 private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View {
51− VStack(alignment: .leading, spacing: 10) { 126+ let items = filtered(c.items)
52− HStack { 127+ // une collection vidée par le filtre s'efface (sauf si aucune n'a rien)
53− Label(c.name, systemImage: "folder.fill") 128+ if !items.isEmpty || (filterUniverse == nil && searchText.isEmpty) {
54− .font(.headline) 129+ VStack(alignment: .leading, spacing: 10) {
55− Spacer() 130+ HStack {
56− Text("\(c.items.count)") 131+ Label(c.name, systemImage: "folder.fill")
57− .font(.system(.caption, design: .monospaced).weight(.bold)) 132+ .font(.headline)
58− .foregroundStyle(.secondary) 133+ Spacer()
59− if favorites.collections.count > 1 { 134+ Text("\(items.count)")
60− Menu { 135+ .font(.system(.caption, design: .monospaced).weight(.bold))
61− Button(role: .destructive) { 136+ .foregroundStyle(.secondary)
62− favorites.removeCollection(c.id) 137+ if favorites.collections.count > 1 {
63− } label: { Label("Supprimer la collection", systemImage: "trash") } 138+ Menu {
64− } label: { 139+ Button(role: .destructive) {
65− Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) 140+ favorites.removeCollection(c.id)
141+ } label: { Label("Supprimer la collection", systemImage: "trash") }
142+ } label: {
143+ Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
144+ }
66 } 145 }
67 } 146 }
68− } 147+ if items.isEmpty {
69− if c.items.isEmpty { 148+ Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.")
70− Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.") 149+ .font(.caption).foregroundStyle(.tertiary)
71− .font(.caption).foregroundStyle(.tertiary) 150+ }
72− } 151+ ForEach(items) { item in
73− ForEach(c.items) { item in 152+ NavigationLink(value: item) { KAItemRow(item: item) }
74− NavigationLink(value: item) { KAItemRow(item: item) } 153+ .buttonStyle(.plain)
75− .buttonStyle(.plain) 154+ .contextMenu {
76− .contextMenu { 155+ ForEach(favorites.collections.filter { $0.id != c.id }) { other in
77− ForEach(favorites.collections.filter { $0.id != c.id }) { other in 156+ Button { favorites.move(item, to: other.id) } label: {
78− Button { favorites.move(item, to: other.id) } label: { 157+ Label("Déplacer vers \(other.name)", systemImage: "folder")
79− Label("Déplacer vers \(other.name)", systemImage: "folder") 158+ }
159+ }
160+ if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } }
161+ Button(role: .destructive) { favorites.toggle(item) } label: {
162+ Label("Retirer", systemImage: "heart.slash")
80 } 163 }
81 } 164 }
82− if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } } 165+ }
83− Button(role: .destructive) { favorites.toggle(item) } label: {
84− Label("Retirer", systemImage: "heart.slash")
85− }
86− }
87 } 166 }
88 } 167 }
89 } 168 }
added KA/Features/ForYouSection.swift +163 −0
@@ -0,0 +1,163 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ForYouSection.swift — le feed « Pour vous » de l'accueil : un mélange
3+// CLASSÉ (pas chronologique) des univers que l'utilisateur suit vraiment,
4+// en grandes cartes immersives pleine largeur, chaque carte expliquant
5+// honnêtement pourquoi elle est là. Apparaît seulement quand le profil a
6+// assez de signal — jamais de fausse personnalisation à la première ouverture.
7+import SwiftUI
8+
9+struct ForYouSection: View {
10+ let entries: [ForYouEntry]
11+ @Environment(\.colorScheme) private var scheme
12+
13+ var body: some View {
14+ VStack(alignment: .leading, spacing: 12) {
15+ HStack(spacing: 8) {
16+ Image(systemName: "sparkles")
17+ .font(.subheadline.weight(.bold))
18+ .foregroundStyle(KATheme.lime)
19+ .frame(width: 28, height: 28)
20+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
21+ VStack(alignment: .leading, spacing: 0) {
22+ Text("Pour vous").font(KAFont.display(22))
23+ .foregroundStyle(KATheme.ink(scheme))
24+ Text("Selon vos visites — tout reste sur votre appareil")
25+ .font(.caption2).foregroundStyle(.secondary)
26+ }
27+ Spacer()
28+ }
29+ .accessibilityElement(children: .combine)
30+ .accessibilityAddTraits(.isHeader)
31+
32+ ForEach(entries) { entry in
33+ NavigationLink(value: entry.item) {
34+ ForYouCard(entry: entry)
35+ }
36+ .buttonStyle(KAPressStyle())
37+ .kaScrollPop(axis: .vertical)
38+ }
39+ }
40+ }
41+}
42+
43+/// Grande carte immersive : photo pleine largeur, raison en badge accent,
44+/// titre display sur scrim. Sans photo : rangée éditoriale avec pastille.
45+struct ForYouCard: View {
46+ let entry: ForYouEntry
47+ @Environment(\.colorScheme) private var scheme
48+
49+ private var universe: Universe? { Ecosystem.universe(entry.item.universeID) }
50+ private var accent: Color { universe?.accent ?? KATheme.green }
51+
52+ var body: some View {
53+ Group {
54+ if entry.item.imageURL != nil {
55+ immersive
56+ } else {
57+ row
58+ }
59+ }
60+ .accessibilityElement(children: .combine)
61+ .accessibilityLabel("\(entry.reason). \(entry.item.title)\(entry.item.priceLabel.map { ", \($0)" } ?? "")")
62+ }
63+
64+ private var immersive: some View {
65+ ZStack(alignment: .bottomLeading) {
66+ // l'image vit en OVERLAY d'un cadre fixe : sa largeur idéale
67+ // (panoramas!) ne se propage jamais à la mise en page
68+ Color.clear
69+ .frame(height: 200)
70+ .frame(maxWidth: .infinity)
71+ .overlay(KAImage(url: entry.item.imageURL, accent: accent,
72+ symbol: universe?.symbol ?? "sparkles"))
73+ .clipped()
74+ LinearGradient(colors: [.clear, .black.opacity(0.10), .black.opacity(0.82)],
75+ startPoint: .top, endPoint: .bottom)
76+ VStack(alignment: .leading, spacing: 6) {
77+ HStack(spacing: 5) {
78+ Image(systemName: "sparkle").font(.system(size: 8, weight: .bold))
79+ Text(entry.reason.uppercased())
80+ .font(KAFont.mono(8))
81+ .kerning(0.8)
82+ .lineLimit(1)
83+ .minimumScaleFactor(0.8)
84+ }
85+ .padding(.horizontal, 9).padding(.vertical, 5)
86+ .background(accent, in: Capsule())
87+ .foregroundStyle(.white)
88+ Text(entry.item.title)
89+ .font(KAFont.display(19))
90+ .foregroundStyle(.white)
91+ .lineLimit(2)
92+ .multilineTextAlignment(.leading)
93+ HStack(spacing: 8) {
94+ if let p = entry.item.priceLabel {
95+ Text(p)
96+ .font(.system(.subheadline, design: .rounded, weight: .heavy))
97+ .foregroundStyle(KATheme.lime)
98+ }
99+ if let c = entry.item.city {
100+ Text(c).font(.caption).foregroundStyle(.white.opacity(0.78))
101+ }
102+ Spacer(minLength: 0)
103+ if let u = universe {
104+ Text(u.wordmark)
105+ .font(KAFont.mono(9))
106+ .foregroundStyle(.white)
107+ .padding(.horizontal, 8).padding(.vertical, 4)
108+ .background(.white.opacity(0.18), in: Capsule())
109+ }
110+ }
111+ }
112+ .padding(14)
113+ }
114+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
115+ .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
116+ .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))
117+ .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
118+ .fill(accent.opacity(scheme == .dark ? 0.35 : 1))
119+ .offset(x: 6, y: 6))
120+ }
121+
122+ private var row: some View {
123+ HStack(alignment: .top, spacing: 12) {
124+ RoundedRectangle(cornerRadius: 8, style: .continuous)
125+ .fill(accent)
126+ .frame(width: 5)
127+ .padding(.vertical, 2)
128+ VStack(alignment: .leading, spacing: 4) {
129+ HStack(spacing: 5) {
130+ Image(systemName: "sparkle").font(.system(size: 8, weight: .bold))
131+ Text(entry.reason.uppercased())
132+ .font(KAFont.mono(8))
133+ .kerning(0.6)
134+ .lineLimit(1)
135+ .minimumScaleFactor(0.85)
136+ }
137+ .foregroundStyle(accent)
138+ Text(entry.item.title)
139+ .font(.subheadline.weight(.bold))
140+ .foregroundStyle(KATheme.ink(scheme))
141+ .lineLimit(2)
142+ .multilineTextAlignment(.leading)
143+ HStack(spacing: 8) {
144+ if let p = entry.item.priceLabel {
145+ Text(p)
146+ .font(.system(.caption, design: .rounded).weight(.bold))
147+ .foregroundStyle(accent)
148+ .lineLimit(1)
149+ }
150+ if let c = entry.item.city {
151+ Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)
152+ }
153+ Spacer(minLength: 0)
154+ if let u = universe {
155+ KAChip(text: u.wordmark, accent: u.accent)
156+ }
157+ }
158+ }
159+ }
160+ .padding(12)
161+ .kaCard(accent: accent)
162+ }
163+}
modified KA/Features/HomeView.swift +44 −23
@@ -13,6 +13,8 @@ struct HomeView: View {
13 @EnvironmentObject private var favorites: FavoritesStore 13 @EnvironmentObject private var favorites: FavoritesStore
14 @EnvironmentObject private var recents: RecentsStore 14 @EnvironmentObject private var recents: RecentsStore
15 @State private var editorialItems: [String: [KAItem]] = [:] 15 @State private var editorialItems: [String: [KAItem]] = [:]
16+ @StateObject private var perso = PersonalizationStore.shared
17+ @State private var forYou: [ForYouEntry] = []
16 @Environment(\.colorScheme) private var scheme 18 @Environment(\.colorScheme) private var scheme
17 19
18 var body: some View { 20 var body: some View {
@@ -20,6 +22,9 @@ struct HomeView: View {
20 ScrollView { 22 ScrollView {
21 VStack(alignment: .leading, spacing: 24) { 23 VStack(alignment: .leading, spacing: 24) {
22 heroCard 24 heroCard
25+ if !forYou.isEmpty {
26+ ForYouSection(entries: forYou)
27+ }
23 launcher 28 launcher
24 pulseSection 29 pulseSection
25 resumeStrip 30 resumeStrip
@@ -207,32 +212,35 @@ struct HomeView: View {
207 VStack(alignment: .leading, spacing: 10) { 212 VStack(alignment: .leading, spacing: 10) {
208 UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green, 213 UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green,
209 detail: "EN DIRECT") 214 detail: "EN DIRECT")
210− ScrollView(.horizontal, showsIndicators: false) { 215+ // mosaïque dashboard : les 6 plus gros compteurs, en tuiles franches
211− HStack(spacing: 10) { 216+ LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
212− ForEach(pulse, id: \.0.id) { (u, n) in 217+ GridItem(.flexible(), spacing: 12)], spacing: 12) {
213− NavigationLink(value: u.id) { 218+ ForEach(pulse.prefix(6), id: \.0.id) { (u, n) in
214− HStack(spacing: 10) { 219+ NavigationLink(value: u.id) {
215− KALogo(universe: u, size: 30) 220+ VStack(alignment: .leading, spacing: 6) {
216− VStack(alignment: .leading, spacing: 1) { 221+ HStack {
217− KACountUp(value: n, font: KAFont.display(17), color: u.accent) 222+ KALogo(universe: u, size: 26)
218− Text(u.unit.uppercased()) 223+ Spacer()
219− .font(KAFont.mono(8)) 224+ Circle().fill(u.accent).frame(width: 7, height: 7)
220− .foregroundStyle(.secondary)
221− .lineLimit(1)
222− }
223 } 225 }
224− .padding(.horizontal, 13).padding(.vertical, 10) 226+ KACountUp(value: n, font: KAFont.display(21), color: u.accent)
225− .kaCard(accent: u.accent) 227+ Text(u.unit.uppercased())
228+ .font(KAFont.mono(8))
229+ .foregroundStyle(.secondary)
230+ .lineLimit(1)
231+ .minimumScaleFactor(0.8)
226 } 232 }
227− .buttonStyle(.plain) 233+ .padding(12)
228− .kaScrollPop() 234+ .frame(maxWidth: .infinity, alignment: .leading)
229− } 235+ .kaCard(accent: u.accent)
230− if pulse.isEmpty {
231− Text("Le pouls de l'écosystème…")
232− .font(.caption).foregroundStyle(.tertiary).padding(10)
233 } 236 }
237+ .buttonStyle(KAPressStyle())
238+ .kaScrollPop(axis: .vertical)
234 } 239 }
235− .padding(.vertical, 4) 240+ }
241+ if pulse.isEmpty {
242+ Text("Le pouls de l'écosystème…")
243+ .font(.caption).foregroundStyle(.tertiary).padding(10)
236 } 244 }
237 } 245 }
238 .accessibilityLabel("Le pouls de l'écosystème en direct") 246 .accessibilityLabel("Le pouls de l'écosystème en direct")
@@ -420,7 +428,7 @@ struct HomeView: View {
420 428
421 // MARK: données 429 // MARK: données
422 430
423− /// Univers mis en avant selon l'heure + les préférences d'onboarding. 431+ /// Univers mis en avant : affinité apprise > favoris d'onboarding > heure.
424 private var contextualUniverses: [Universe] { 432 private var contextualUniverses: [Universe] {
425 let h = Calendar.current.component(.hour, from: Date()) 433 let h = Calendar.current.component(.hour, from: Date())
426 let weekend = Calendar.current.isDateInWeekend(Date()) 434 let weekend = Calendar.current.isDateInWeekend(Date())
@@ -434,6 +442,11 @@ struct HomeView: View {
434 } 442 }
435 let favs = favUniversesRaw.split(separator: ",").map(String.init) 443 let favs = favUniversesRaw.split(separator: ",").map(String.init)
436 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) } 444 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }
445+ if perso.enabled, perso.profile.hasSignals {
446+ for u in perso.profile.topUniverses(2).reversed() where !ids.prefix(4).contains(u) {
447+ ids.insert(u, at: 0)
448+ }
449+ }
437 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil } 450 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }
438 } 451 }
439 452
@@ -455,6 +468,14 @@ struct HomeView: View {
455 return items.isEmpty ? nil : (u, items) 468 return items.isEmpty ? nil : (u, items)
456 } 469 }
457 loading = false 470 loading = false
471+ // feed « Pour vous » — seulement avec un vrai profil (jamais de fausse perso)
472+ if perso.enabled, perso.profile.hasSignals {
473+ let seen = Set(recents.viewed.prefix(12).map(\.id))
474+ let feed = await RecommendationService.feed(profile: perso.profile, excluding: seen)
475+ withAnimation(.snappy) { forYou = feed }
476+ } else {
477+ forYou = []
478+ }
458 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil 479 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil
459 var counts: [(Universe, Int)] = [] 480 var counts: [(Universe, Int)] = []
460 await withTaskGroup(of: (String, Int?).self) { group in 481 await withTaskGroup(of: (String, Int?).self) { group in
modified KA/Features/MapView.swift +42 −2
@@ -57,7 +57,11 @@ struct UnifiedMapView: View {
57 center: .init(latitude: 46.81, longitude: -71.21), 57 center: .init(latitude: 46.81, longitude: -71.21),
58 span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) 58 span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))
59 @State private var items: [KAItem] = [] 59 @State private var items: [KAItem] = []
60− @State private var enabled: Set<String> = ["lou-ka", "immo-ka"] 60+ // couches actives, PERSISTÉES entre les sessions
61+ @AppStorage("ka.map.layers") private var layersRaw = "lou-ka,immo-ka"
62+ @State private var enabled: Set<String> = []
63+ @State private var showFavorites = false
64+ @EnvironmentObject private var favorites: FavoritesStore
61 @State private var loading = false 65 @State private var loading = false
62 @State private var zoneDirty = false 66 @State private var zoneDirty = false
63 @State private var selectedCluster: MapCluster? 67 @State private var selectedCluster: MapCluster?
@@ -77,9 +81,26 @@ struct UnifiedMapView: View {
77 private var vpZoomedEnough: Bool { 81 private var vpZoomedEnough: Bool {
78 visibleRegion.span.latitudeDelta <= 0.16 && visibleRegion.span.longitudeDelta <= 0.24 82 visibleRegion.span.latitudeDelta <= 0.16 && visibleRegion.span.longitudeDelta <= 0.24
79 } 83 }
80− private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } } 84+ /// Favoris géolocalisés dans la zone visible (couche « ♥ »)
85+ private var favoriteItems: [KAItem] {
86+ guard showFavorites else { return [] }
87+ let r = visibleRegion
88+ return favorites.allItems.filter { it in
89+ guard let la = it.latitude, let lo = it.longitude else { return false }
90+ return abs(la - r.center.latitude) <= r.span.latitudeDelta / 2
91+ && abs(lo - r.center.longitude) <= r.span.longitudeDelta / 2
92+ }
93+ }
94+ private var visibleItems: [KAItem] {
95+ var out = items.filter { enabled.contains($0.universeID) }
96+ let ids = Set(out.map(\.id))
97+ out += favoriteItems.filter { !ids.contains($0.id) }
98+ return out
99+ }
81 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) } 100 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }
82 101
102+ private func persistLayers() { layersRaw = enabled.sorted().joined(separator: ",") }
103+
83 var body: some View { 104 var body: some View {
84 NavigationStack { 105 NavigationStack {
85 ZStack(alignment: .bottom) { 106 ZStack(alignment: .bottom) {
@@ -130,6 +151,8 @@ struct UnifiedMapView: View {
130 .presentationBackgroundInteraction(.enabled(upThrough: .medium)) 151 .presentationBackgroundInteraction(.enabled(upThrough: .medium))
131 } 152 }
132 .task { 153 .task {
154+ // restaure les couches choisies à la dernière session
155+ enabled = Set(layersRaw.split(separator: ",").map(String.init))
133 // pilotage debug : couche Vrai-Prix active + zoom quartier 156 // pilotage debug : couche Vrai-Prix active + zoom quartier
134 // (defaults write com.groupeka.ka ka.debug.mapvp -bool true) 157 // (defaults write com.groupeka.ka ka.debug.mapvp -bool true)
135 if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") { 158 if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") {
@@ -190,11 +213,28 @@ struct UnifiedMapView: View {
190 private var chips: some View { 213 private var chips: some View {
191 ScrollView(.horizontal, showsIndicators: false) { 214 ScrollView(.horizontal, showsIndicators: false) {
192 HStack(spacing: 8) { 215 HStack(spacing: 8) {
216+ // couche transversale : vos favoris géolocalisés, tous univers
217+ if !favorites.allItems.isEmpty {
218+ Button {
219+ Haptics.tap()
220+ showFavorites.toggle()
221+ selectedCluster = nil
222+ } label: {
223+ Label("Favoris", systemImage: showFavorites ? "heart.fill" : "heart")
224+ .font(.system(.caption, design: .monospaced).weight(.bold))
225+ .padding(.horizontal, 11).padding(.vertical, 8)
226+ .background(showFavorites ? Color.red : KATheme.surface(scheme), in: Capsule())
227+ .foregroundStyle(showFavorites ? .white : .secondary)
228+ .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))
229+ }
230+ .accessibilityLabel("\(showFavorites ? "Masquer" : "Afficher") vos favoris sur la carte")
231+ }
193 ForEach(mapUniverses) { u in 232 ForEach(mapUniverses) { u in
194 let on = enabled.contains(u.id) 233 let on = enabled.contains(u.id)
195 Button { 234 Button {
196 Haptics.tap() 235 Haptics.tap()
197 if on { enabled.remove(u.id) } else { enabled.insert(u.id) } 236 if on { enabled.remove(u.id) } else { enabled.insert(u.id) }
237+ persistLayers()
198 selectedCluster = nil 238 selectedCluster = nil
199 Task { await loadZone() } 239 Task { await loadZone() }
200 } label: { 240 } label: {
added KA/Features/MortgageCard.swift +76 −0
@@ -0,0 +1,76 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// MortgageCard.swift — l'estimateur hypothécaire des fiches Immo·Ka : mise de
3+// fonds ajustable, taux modifiable, paiement mensuel calculé LOCALEMENT et en
4+// toute transparence (formule affichée en pied). Aucune donnée n'est envoyée.
5+import SwiftUI
6+
7+struct MortgageCard: View {
8+ let price: Double
9+ let accent: Color
10+ @State private var downFraction: Double = 0.20
11+ @State private var rate: Double = 4.5
12+ @State private var years: Int = 25
13+ @Environment(\.colorScheme) private var scheme
14+
15+ private var payment: Double {
16+ MortgageMath.monthlyPayment(price: price, downFraction: downFraction,
17+ annualRate: rate, years: years)
18+ }
19+ private var down: Double { price * downFraction }
20+
21+ var body: some View {
22+ VStack(alignment: .leading, spacing: 12) {
23+ UniverseSectionHeader(title: "Paiement hypothécaire estimé", accent: accent)
24+
25+ HStack(alignment: .firstTextBaseline, spacing: 6) {
26+ Text(payment.money0)
27+ .font(KAFont.display(28))
28+ .foregroundStyle(accent)
29+ .contentTransition(.numericText())
30+ Text("/ mois")
31+ .font(KAFont.mono(10))
32+ .foregroundStyle(.secondary)
33+ }
34+ .animation(.snappy, value: payment)
35+
36+ VStack(alignment: .leading, spacing: 4) {
37+ HStack {
38+ Text("Mise de fonds")
39+ .font(.caption.weight(.semibold))
40+ Spacer()
41+ Text("\(Int(downFraction * 100)) % · \(down.money0)")
42+ .font(KAFont.mono(10))
43+ .foregroundStyle(accent)
44+ }
45+ Slider(value: $downFraction, in: 0.05...0.5, step: 0.05)
46+ .tint(accent)
47+ .accessibilityLabel("Mise de fonds")
48+ .accessibilityValue("\(Int(downFraction * 100)) pour cent")
49+ }
50+
51+ HStack(spacing: 12) {
52+ Stepper(value: $rate, in: 1...10, step: 0.25) {
53+ VStack(alignment: .leading, spacing: 1) {
54+ Text("TAUX").font(KAFont.mono(8)).foregroundStyle(.secondary)
55+ Text(String(format: "%.2f %%", rate))
56+ .font(.subheadline.weight(.bold))
57+ }
58+ }
59+ .accessibilityLabel("Taux d'intérêt annuel")
60+ Stepper(value: $years, in: 5...30, step: 5) {
61+ VStack(alignment: .leading, spacing: 1) {
62+ Text("AMORTISSEMENT").font(KAFont.mono(8)).foregroundStyle(.secondary)
63+ Text("\(years) ans").font(.subheadline.weight(.bold))
64+ }
65+ }
66+ .accessibilityLabel("Période d'amortissement")
67+ }
68+
69+ Text("Estimation indicative calculée sur votre appareil — taxes, assurance SCHL et frais exclus.")
70+ .font(.caption2).foregroundStyle(.tertiary)
71+ }
72+ .padding(14)
73+ .frame(maxWidth: .infinity, alignment: .leading)
74+ .kaCard(accent: accent)
75+ }
76+}
modified KA/Features/ProfileView.swift +39 −0
@@ -10,7 +10,10 @@ struct ProfileView: View {
10 @StateObject private var kaid = KAIDManager.shared 10 @StateObject private var kaid = KAIDManager.shared
11 @EnvironmentObject private var favorites: FavoritesStore 11 @EnvironmentObject private var favorites: FavoritesStore
12 @EnvironmentObject private var recents: RecentsStore 12 @EnvironmentObject private var recents: RecentsStore
13+ @StateObject private var perso = PersonalizationStore.shared
14+ @AppStorage(PersonalizationStore.enabledKey) private var persoEnabled = true
13 @State private var showHubSite = false 15 @State private var showHubSite = false
16+ @State private var confirmReset = false
14 17
15 private var favIDs: Set<String> { 18 private var favIDs: Set<String> {
16 Set(favUniversesRaw.split(separator: ",").map(String.init)) 19 Set(favUniversesRaw.split(separator: ",").map(String.init))
@@ -80,6 +83,33 @@ struct ProfileView: View {
80 } 83 }
81 } 84 }
82 85
86+ Section {
87+ Toggle(isOn: $persoEnabled) {
88+ Label("Recommandations personnalisées", systemImage: "sparkles")
89+ }
90+ if persoEnabled, perso.profile.hasSignals {
91+ let p = perso.profile
92+ if !p.affinity.isEmpty {
93+ LabeledContent("Univers suivis",
94+ value: p.topUniverses(3)
95+ .compactMap { Ecosystem.universe($0)?.name }
96+ .joined(separator: ", "))
97+ }
98+ if !p.topCities.isEmpty {
99+ LabeledContent("Villes", value: p.topCities.prefix(3).joined(separator: ", "))
100+ }
101+ LabeledContent("Signaux enregistrés", value: "\(p.eventCount)")
102+ }
103+ Button(role: .destructive) { confirmReset = true } label: {
104+ Label("Effacer ce que KA a appris", systemImage: "trash")
105+ }
106+ .disabled(perso.events.isEmpty)
107+ } header: {
108+ Text("Personnalisation")
109+ } footer: {
110+ Text("Vos consultations, favoris et recherches personnalisent l'accueil et le feed « Pour vous ». Tout reste sur cet appareil — rien n'est envoyé aux serveurs.")
111+ }
112+
83 Section { 113 Section {
84 ForEach(Ecosystem.all) { u in 114 ForEach(Ecosystem.all) { u in
85 Button { 115 Button {
@@ -156,6 +186,15 @@ struct ProfileView: View {
156 SiteBrowserCover(title: "Groupe KA", accent: KATheme.green, 186 SiteBrowserCover(title: "Groupe KA", accent: KATheme.green,
157 logoID: "groupe-ka", url: Ecosystem.hubURL) 187 logoID: "groupe-ka", url: Ecosystem.hubURL)
158 } 188 }
189+ .confirmationDialog("Effacer les données d'apprentissage ?",
190+ isPresented: $confirmReset, titleVisibility: .visible) {
191+ Button("Tout effacer", role: .destructive) {
192+ perso.reset()
193+ Haptics.success()
194+ }
195+ } message: {
196+ Text("Le feed « Pour vous » repartira de zéro. Vos favoris et votre historique ne sont pas touchés.")
197+ }
159 } 198 }
160 } 199 }
161 } 200 }
modified KA/Features/SearchView.swift +83 −23
@@ -32,7 +32,8 @@ struct SearchView: View {
32 } 32 }
33 } 33 }
34 private func price(_ i: KAItem) -> Double { 34 private func price(_ i: KAItem) -> Double {
35− Double(i.priceLabel?.filter { $0.isNumber } ?? "") ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0) 35+ i.price ?? Double(i.priceLabel?.filter { $0.isNumber } ?? "")
36+ ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0)
36 } 37 }
37 38
38 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) } 39 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }
@@ -62,8 +63,18 @@ struct SearchView: View {
62 .padding(16) 63 .padding(16)
63 } 64 }
64 .background(KATheme.paper(scheme)) 65 .background(KATheme.paper(scheme))
65− .navigationTitle("Recherche") 66+ .navigationTitle("")
66− .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…") 67+ .navigationBarTitleDisplayMode(.inline)
68+ .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),
69+ prompt: "appartement Rouyn, resto italien, Corolla…")
70+ .searchSuggestions {
71+ // complétions pendant la frappe : l'historique d'abord
72+ if !query.isEmpty {
73+ ForEach(history.filter { $0.localizedCaseInsensitiveContains(query) && $0 != query }.prefix(4), id: \.self) { h in
74+ Label(h, systemImage: "clock.arrow.circlepath").searchCompletion(h)
75+ }
76+ }
77+ }
67 .onSubmit(of: .search) { Task { await search() } } 78 .onSubmit(of: .search) { Task { await search() } }
68 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } 79 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
69 .toolbar { 80 .toolbar {
@@ -122,35 +133,83 @@ struct SearchView: View {
122 } 133 }
123 } 134 }
124 135
136+ /// Idées de départ : chaque tuile porte l'ADN visuel de son univers.
137+ private static let ideas: [(query: String, universeID: String, symbol: String)] = [
138+ ("4½ à Québec", "lou-ka", "key.fill"),
139+ ("resto italien Montréal", "resto-ka", "fork.knife"),
140+ ("Toyota Corolla", "auto-ka", "car.fill"),
141+ ("emploi infirmière", "job-ka", "briefcase.fill"),
142+ ("spectacle ce week-end", "sorti-ka", "ticket.fill"),
143+ ("maison Gatineau", "immo-ka", "house.fill"),
144+ ]
145+
125 private var suggestions: some View { 146 private var suggestions: some View {
126 VStack(alignment: .leading, spacing: 14) { 147 VStack(alignment: .leading, spacing: 14) {
148+ // manchette éditoriale
149+ VStack(alignment: .leading, spacing: 4) {
150+ HStack(spacing: 8) {
151+ Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)
152+ Text("UNE BARRE · \(searchables.count) UNIVERS INTERROGÉS D'UN COUP")
153+ .font(KAFont.mono(9)).kerning(1.0)
154+ .foregroundStyle(KATheme.ink2(scheme))
155+ .lineLimit(1).minimumScaleFactor(0.8)
156+ }
157+ Text("Chercher dans\ntout le Québec")
158+ .font(KAFont.display(32))
159+ .foregroundStyle(KATheme.ink(scheme))
160+ }
127 if !history.isEmpty { 161 if !history.isEmpty {
128− Text("Récemment cherché").font(.headline) 162+ UniverseSectionHeader(title: "Récemment cherché", accent: KATheme.green)
129− ForEach(history.prefix(6), id: \.self) { h in 163+ KAFlow(spacing: 8) {
130− Button { 164+ ForEach(history.prefix(6), id: \.self) { h in
131− query = h 165+ Button {
132− Task { await search(h) } 166+ query = h
133− } label: { 167+ Task { await search(h) }
134− HStack { 168+ } label: {
135− Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) 169+ HStack(spacing: 6) {
136− Text(h) 170+ Image(systemName: "clock.arrow.circlepath")
137− Spacer() 171+ .font(.caption2).foregroundStyle(.secondary)
172+ Text(h).font(.caption.weight(.semibold)).lineLimit(1)
173+ }
174+ .padding(.horizontal, 12).padding(.vertical, 9)
175+ .background(KATheme.surface(scheme), in: Capsule())
176+ .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
177+ .foregroundStyle(KATheme.ink(scheme))
138 } 178 }
139− .padding(12).kaCard() 179+ .buttonStyle(KAPressStyle())
140 } 180 }
141− .buttonStyle(.plain)
142 } 181 }
143 } 182 }
144− Text("Idées").font(.headline) 183+ UniverseSectionHeader(title: "Idées de départ", accent: KATheme.green)
145− ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in 184+ LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
146− Button { query = s; Task { await search(s) } } label: { 185+ GridItem(.flexible(), spacing: 12)], spacing: 12) {
147− HStack { 186+ ForEach(Self.ideas, id: \.query) { idea in
148− Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green) 187+ let u = Ecosystem.universe(idea.universeID)
149− Text(s); Spacer() 188+ Button { query = idea.query; Task { await search(idea.query) } } label: {
189+ VStack(alignment: .leading, spacing: 8) {
190+ Image(systemName: idea.symbol)
191+ .font(.headline)
192+ .foregroundStyle(.white)
193+ .frame(width: 34, height: 34)
194+ .background(u?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
195+ Text(idea.query)
196+ .font(.subheadline.weight(.bold))
197+ .foregroundStyle(KATheme.ink(scheme))
198+ .lineLimit(2, reservesSpace: true)
199+ .multilineTextAlignment(.leading)
200+ if let u {
201+ Text(u.wordmark.uppercased())
202+ .font(KAFont.mono(8))
203+ .foregroundStyle(u.accent)
204+ }
205+ }
206+ .padding(13)
207+ .frame(maxWidth: .infinity, alignment: .topLeading)
208+ .kaCard(accent: u?.accent)
150 } 209 }
151− .padding(12).kaCard() 210+ .buttonStyle(KAPressStyle())
211+ .kaScrollPop(axis: .vertical)
152 } 212 }
153− .buttonStyle(.plain)
154 } 213 }
155 } 214 }
156 } 215 }
@@ -190,6 +249,7 @@ struct SearchView: View {
190 var hist = history.filter { $0 != q } 249 var hist = history.filter { $0 != q }
191 hist.insert(q, at: 0) 250 hist.insert(q, at: 0)
192 historyRaw = hist.prefix(10).joined(separator: "\n") 251 historyRaw = hist.prefix(10).joined(separator: "\n")
252+ PersonalizationStore.shared.recordSearch(q)
193 253
194 let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) } 254 let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) }
195 var found: [String: [KAItem]] = [:] 255 var found: [String: [KAItem]] = [:]
modified KA/Features/UniverseShowcase.swift +3 −1
@@ -145,9 +145,11 @@ struct UniverseShowcaseCard: View {
145 145
146 var body: some View { 146 var body: some View {
147 ZStack(alignment: .bottomLeading) { 147 ZStack(alignment: .bottomLeading) {
148− KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol) 148+ // image en overlay d'un cadre fixe : jamais de débordement de layout
149+ Color.clear
149 .frame(height: 224) 150 .frame(height: 224)
150 .frame(maxWidth: .infinity) 151 .frame(maxWidth: .infinity)
152+ .overlay(KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol))
151 .clipped() 153 .clipped()
152 LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)], 154 LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)],
153 startPoint: .top, endPoint: .bottom) 155 startPoint: .top, endPoint: .bottom)
modified KA/Features/UniversesView.swift +327 −120
@@ -48,25 +48,47 @@ struct UniversesView: View {
48 @State private var totals: [String: Int] = [:] 48 @State private var totals: [String: Int] = [:]
49 @Environment(\.colorScheme) private var scheme 49 @Environment(\.colorScheme) private var scheme
50 50
51− private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
52 @State private var showMap = false 51 @State private var showMap = false
53 @State private var path = NavigationPath() 52 @State private var path = NavigationPath()
54 53
55 var body: some View { 54 var body: some View {
56 NavigationStack(path: $path) { 55 NavigationStack(path: $path) {
57 ScrollView { 56 ScrollView {
58− LazyVGrid(columns: columns, spacing: 14) { 57+ VStack(alignment: .leading, spacing: 14) {
58+ // manchette éditoriale — l'index de l'écosystème
59+ VStack(alignment: .leading, spacing: 4) {
60+ HStack(spacing: 8) {
61+ Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)
62+ Text("L'ÉCOSYSTÈME · \(Ecosystem.all.count) UNIVERS")
63+ .font(KAFont.mono(9.5)).kerning(1.1)
64+ .foregroundStyle(KATheme.ink2(scheme))
65+ }
66+ Text("Explorer")
67+ .font(KAFont.display(36))
68+ .foregroundStyle(KATheme.ink(scheme))
69+ Text("Mille sites. Un seul KA.")
70+ .font(.subheadline)
71+ .foregroundStyle(KATheme.ink2(scheme))
72+ }
73+ .padding(.bottom, 6)
59 ForEach(Ecosystem.all) { u in 74 ForEach(Ecosystem.all) { u in
60 NavigationLink(value: u.id) { 75 NavigationLink(value: u.id) {
61− UniverseCard(universe: u, total: totals[u.id]) 76+ UniverseBand(universe: u, total: totals[u.id])
62 } 77 }
63 .buttonStyle(KAPressStyle()) 78 .buttonStyle(KAPressStyle())
79+ .kaScrollPop(axis: .vertical)
64 } 80 }
65 } 81 }
66 .padding(16) 82 .padding(16)
67 } 83 }
68− .background(KATheme.paper(scheme)) 84+ .background {
69− .navigationTitle("Univers") 85+ ZStack {
86+ KATheme.paper(scheme)
87+ KAAurora()
88+ }
89+ .ignoresSafeArea()
90+ }
91+ .navigationTitle("")
70 .toolbar { 92 .toolbar {
71 ToolbarItem(placement: .topBarTrailing) { 93 ToolbarItem(placement: .topBarTrailing) {
72 Button { showMap = true } label: { Image(systemName: "map.fill") } 94 Button { showMap = true } label: { Image(systemName: "map.fill") }
@@ -100,36 +122,69 @@ struct UniversesView: View {
100 } 122 }
101 } 123 }
102 124
103−struct UniverseCard: View { 125+/// Bande éditoriale d'un univers — l'index magazine de l'écosystème :
126+/// gradient à l'accent, logo en filigrane, grand wordmark, compteur roulant.
127+struct UniverseBand: View {
104 let universe: Universe 128 let universe: Universe
105 let total: Int? 129 let total: Int?
106 @Environment(\.colorScheme) private var scheme 130 @Environment(\.colorScheme) private var scheme
107 131
108 var body: some View { 132 var body: some View {
109− VStack(alignment: .leading, spacing: 10) { 133+ ZStack(alignment: .topTrailing) {
110− HStack { 134+ RoundedRectangle(cornerRadius: 18, style: .continuous)
111− KALogo(universe: universe, size: 40) 135+ .fill(LinearGradient(
112− Spacer() 136+ colors: [universe.accent.opacity(scheme == .dark ? 0.30 : 0.20),
113− Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) 137+ universe.accent.opacity(scheme == .dark ? 0.08 : 0.03)],
114− } 138+ startPoint: .topTrailing, endPoint: .bottomLeading))
115− KAWordmark(universe: universe, size: 17) 139+ .background(KATheme.surface(scheme),
116− Text(universe.tagline) 140+ in: RoundedRectangle(cornerRadius: 18, style: .continuous))
117− .font(.caption).foregroundStyle(KATheme.ink2(scheme)) 141+ KALogo(universe: universe, size: 120)
118− .lineLimit(2, reservesSpace: true) 142+ .opacity(scheme == .dark ? 0.14 : 0.10)
119− if let total { 143+ .rotationEffect(.degrees(9))
120− Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)") 144+ .offset(x: 26, y: -18)
121− .font(KAFont.mono(10)) 145+ .accessibilityHidden(true)
122− .foregroundStyle(universe.accent) 146+ HStack(spacing: 12) {
123− .contentTransition(.numericText()) 147+ VStack(alignment: .leading, spacing: 5) {
124− } else { 148+ KAWordmark(universe: universe, size: 23)
125− Text("—").font(KAFont.mono(10, bold: false)) 149+ Text(universe.tagline)
126− .foregroundStyle(.tertiary) 150+ .font(.caption)
151+ .foregroundStyle(KATheme.ink2(scheme))
152+ .lineLimit(2)
153+ .multilineTextAlignment(.leading)
154+ HStack(spacing: 7) {
155+ Circle().fill(universe.accent).frame(width: 7, height: 7)
156+ if let total {
157+ KACountUp(value: total, font: KAFont.display(15),
158+ color: universe.accent)
159+ Text(universe.unit.uppercased())
160+ .font(KAFont.mono(8))
161+ .foregroundStyle(KATheme.ink2(scheme))
162+ .lineLimit(1)
163+ } else {
164+ Text("EN DIRECT")
165+ .font(KAFont.mono(8))
166+ .foregroundStyle(universe.accent)
167+ }
168+ }
169+ .padding(.top, 2)
170+ }
171+ Spacer(minLength: 0)
172+ Image(systemName: "arrow.right")
173+ .font(.subheadline.weight(.bold))
174+ .foregroundStyle(.white)
175+ .frame(width: 32, height: 32)
176+ .background(universe.accent, in: Circle())
127 } 177 }
178+ .padding(16)
128 } 179 }
129− .padding(14) 180+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
130− .frame(maxWidth: .infinity, alignment: .leading) 181+ .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
131− .kaCard(accent: universe.accent) 182+ .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.8), lineWidth: 1.5))
183+ .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
184+ .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 0.95))
185+ .offset(x: 6, y: 6))
132 .accessibilityElement(children: .combine) 186 .accessibilityElement(children: .combine)
187+ .accessibilityLabel("\(universe.name) — \(universe.tagline)")
133 } 188 }
134 } 189 }
135 190
@@ -294,6 +349,11 @@ struct UniverseHomeView: View {
294 Task { await load() } 349 Task { await load() }
295 } 350 }
296 .padding(.top, 12) 351 .padding(.top, 12)
352+ // les chips MÉTIER de l'univers (4½, Sushi, Télétravail, ≤ 300 k$…)
353+ UniverseQuickChips(universe: universe, params: $filterParams) {
354+ Task { await load() }
355+ }
356+ .padding(.top, 8)
297 } 357 }
298 } 358 }
299 359
@@ -343,9 +403,50 @@ struct UniverseHomeView: View {
343 403
344 /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine 404 /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine
345 /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos 405 /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos
346− /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. 406+ /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. Sorti·Ka a son
407+ /// propre rendu : l'AGENDA (sections par horizon temporel).
347 @ViewBuilder 408 @ViewBuilder
348 private var magazine: some View { 409 private var magazine: some View {
410+ if universe.id == "sorti-ka" {
411+ agenda
412+ } else {
413+ magazineBody
414+ }
415+ }
416+
417+ /// L'agenda Sorti·Ka : les événements regroupés par quand ça se passe.
418+ @ViewBuilder
419+ private var agenda: some View {
420+ let groups = Dictionary(grouping: items) { EventBucket.bucket(iso: $0.date) }
421+ ForEach(EventBucket.allCases.sorted(), id: \.rawValue) { bucket in
422+ if let group = groups[bucket], !group.isEmpty {
423+ UniverseSectionHeader(title: bucket.label, accent: universe.accent,
424+ detail: "\(group.count)")
425+ let visuals = group.filter { $0.imageURL != nil }
426+ if visuals.count >= 2 {
427+ LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
428+ GridItem(.flexible(), spacing: 12)], spacing: 12) {
429+ ForEach(group) { it in
430+ NavigationLink(value: it) {
431+ UniverseMosaicCell(item: it, universe: universe)
432+ }
433+ .buttonStyle(KAPressStyle())
434+ .kaScrollPop(axis: .vertical)
435+ }
436+ }
437+ } else {
438+ ForEach(group) { it in
439+ NavigationLink(value: it) { KAItemRow(item: it) }
440+ .buttonStyle(KAPressStyle())
441+ .kaScrollPop(axis: .vertical)
442+ }
443+ }
444+ }
445+ }
446+ }
447+
448+ @ViewBuilder
449+ private var magazineBody: some View {
349 let visuals = items.filter { $0.imageURL != nil } 450 let visuals = items.filter { $0.imageURL != nil }
350 if visuals.count >= 3, let showcase = visuals.first { 451 if visuals.count >= 3, let showcase = visuals.first {
351 let featured = Array(visuals.dropFirst().prefix(6)) 452 let featured = Array(visuals.dropFirst().prefix(6))
@@ -441,34 +542,178 @@ struct ItemDetailView: View {
441 /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste. 542 /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste.
442 private var it: KAItem { enriched ?? item } 543 private var it: KAItem { enriched ?? item }
443 private var accent: Color { universe?.accent ?? KATheme.green } 544 private var accent: Color { universe?.accent ?? KATheme.green }
545+ @Environment(\.openURL) private var openURL
546+
547+ /// L'action principale parle la langue de l'univers — pas un générique.
548+ private var sourceCTA: (label: String, symbol: String) {
549+ switch it.universeID {
550+ case "job-ka": return ("Postuler chez l'employeur", "paperplane.fill")
551+ case "lou-ka": return ("Voir l'annonce du logement", "key.fill")
552+ case "immo-ka": return ("Voir chez le courtier", "house.fill")
553+ case "auto-ka": return ("Voir chez le concessionnaire", "car.fill")
554+ case "resto-ka": return ("Fiche du restaurant", "fork.knife")
555+ case "sorti-ka": return ("Billets & infos", "ticket.fill")
556+ case "food-ka": return ("Voir en épicerie", "cart.fill")
557+ case "fabri-ka": return ("Voir la boutique", "bag.fill")
558+ default: return ("Voir à la source", "arrow.up.right.square")
559+ }
560+ }
444 561
445 var body: some View { 562 var body: some View {
446 ScrollView { 563 ScrollView {
447− VStack(alignment: .leading, spacing: 16) { 564+ VStack(alignment: .leading, spacing: 0) {
448− if !it.imageURLs.isEmpty { 565+ if !it.imageURLs.isEmpty { heroGallery }
449− TabView { 566+ contentCard
450− ForEach(it.imageURLs, id: \.self) { img in 567+ }
451− KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") 568+ }
452− .onTapGesture { Haptics.tap(); fullScreenImage = img } 569+ .ignoresSafeArea(edges: it.imageURLs.isEmpty ? [] : .top)
453− .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir") 570+ .background(KATheme.paper(scheme))
454− .accessibilityAddTraits(.isButton) 571+ .navigationBarTitleDisplayMode(.inline)
455− } 572+ .toolbarBackground(it.imageURLs.isEmpty ? .automatic : .hidden, for: .navigationBar)
456− } 573+ .toolbar {
457− .tabViewStyle(.page(indexDisplayMode: it.imageURLs.count > 1 ? .automatic : .never)) 574+ ToolbarItem(placement: .topBarTrailing) {
458− .frame(height: 240) 575+ if let url = it.url {
459− .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) 576+ ShareLink(item: url, subject: Text(it.title))
460− .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
461− .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))
462− .overlay(alignment: .topTrailing) {
463− if it.imageURLs.count > 1 {
464− Text("\(it.imageURLs.count) photos")
465− .font(.system(size: 10, design: .monospaced).weight(.bold))
466− .padding(.horizontal, 8).padding(.vertical, 4)
467− .background(.ultraThinMaterial, in: Capsule())
468− .padding(8)
469− }
470− }
471 } 577 }
578+ }
579+ }
580+ .safeAreaInset(edge: .bottom) {
581+ // barre d'action flottante — la safe area étendue du dock la pose
582+ // automatiquement au-dessus de lui, et le contenu défile net
583+ stickyBar
584+ .padding(.horizontal, 12)
585+ .padding(.bottom, 6)
586+ }
587+ .task {
588+ recents.record(item)
589+ PersonalizationStore.shared.record(.view, item: item)
590+ // fiche complète depuis l'API de détail du site (galerie entière,
591+ // description longue, inclusions, estimation Vrai-Prix, heures…)
592+ if let u = universe, enriched == nil,
593+ let full = await DetailService.enrich(item, universe: u) {
594+ withAnimation(.snappy) { enriched = full }
595+ }
596+ if it.universeID == "resto-ka" {
597+ let uid = String(it.id.dropFirst("resto-ka:".count))
598+ menu = await RestoMenuLoader.load(uid: uid)
599+ }
600+ if let u = universe, u.map != nil, similar.isEmpty {
601+ let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? []
602+ similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 }
603+ }
604+ }
605+ .fullScreenCover(item: $fullScreenImage) { img in
606+ ZoomableImageView(url: img, accent: universe?.accent ?? .gray)
607+ }
608+ .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {
609+ ForEach(favorites.collections) { c in
610+ Button(c.name) { favorites.toggle(item, in: c.id) }
611+ }
612+ }
613+ .tint(universe?.accent)
614+ }
615+
616+ // MARK: héros photo PLEIN CADRE (jusque sous la barre d'état)
617+
618+ private var heroGallery: some View {
619+ TabView {
620+ ForEach(it.imageURLs, id: \.self) { img in
621+ KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")
622+ .onTapGesture { Haptics.tap(); fullScreenImage = img }
623+ .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir")
624+ .accessibilityAddTraits(.isButton)
625+ }
626+ }
627+ .tabViewStyle(.page(indexDisplayMode: .never))
628+ .frame(height: 400)
629+ .clipped()
630+ // scrims : lisibilité du bouton retour en haut, fondu vers la carte en bas
631+ .overlay(alignment: .top) {
632+ LinearGradient(colors: [.black.opacity(0.38), .clear],
633+ startPoint: .top, endPoint: .bottom)
634+ .frame(height: 110)
635+ .allowsHitTesting(false)
636+ }
637+ .overlay(alignment: .bottomTrailing) {
638+ if it.imageURLs.count > 1 {
639+ Label("\(it.imageURLs.count) photos", systemImage: "photo.stack")
640+ .font(.system(size: 10, design: .monospaced).weight(.bold))
641+ .padding(.horizontal, 9).padding(.vertical, 5)
642+ .background(.ultraThinMaterial, in: Capsule())
643+ .padding(.trailing, 14)
644+ .padding(.bottom, 44)
645+ }
646+ }
647+ }
648+
649+ // MARK: barre d'action collante — prix + favori + le geste de l'univers
650+
651+ @ViewBuilder
652+ private var stickyBar: some View {
653+ HStack(spacing: 10) {
654+ VStack(alignment: .leading, spacing: 1) {
655+ if let p = it.priceLabel {
656+ Text(p)
657+ .font(KAFont.display(17))
658+ .foregroundStyle(accent)
659+ .lineLimit(1)
660+ .minimumScaleFactor(0.6)
661+ } else {
662+ Text(universe?.wordmark ?? "KA")
663+ .font(KAFont.display(15))
664+ .foregroundStyle(KATheme.ink(scheme))
665+ }
666+ if let c = it.city {
667+ Text(c).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
668+ }
669+ }
670+ Spacer(minLength: 4)
671+ Button {
672+ if favorites.isFavorite(item) { favorites.toggle(item) }
673+ else if favorites.collections.count > 1 { showCollections = true }
674+ else { favorites.toggle(item) }
675+ } label: {
676+ Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")
677+ .font(.headline)
678+ .foregroundStyle(favorites.isFavorite(item) ? .red : KATheme.ink(scheme))
679+ .frame(width: 44, height: 44)
680+ .background(KATheme.surface(scheme), in: Circle())
681+ .overlay(Circle().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))
682+ }
683+ .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")
684+ if let url = it.url {
685+ Button {
686+ Haptics.tap()
687+ PersonalizationStore.shared.record(.openSource, item: it)
688+ openURL(url)
689+ } label: {
690+ Label(sourceCTA.label, systemImage: sourceCTA.symbol)
691+ .font(.subheadline.weight(.bold))
692+ .lineLimit(1)
693+ .minimumScaleFactor(0.75)
694+ .padding(.horizontal, 16)
695+ .frame(height: 44)
696+ .background(accent, in: Capsule())
697+ .foregroundStyle(.white)
698+ }
699+ .buttonStyle(KAPressStyle())
700+ }
701+ }
702+ .padding(.horizontal, 12)
703+ .padding(.vertical, 8)
704+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
705+ .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
706+ .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.4 : 0.85), lineWidth: 1.5))
707+ .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
708+ .fill(accent.opacity(scheme == .dark ? 0.4 : 0.95))
709+ .offset(x: 5, y: 5))
710+ .shadow(color: .black.opacity(0.15), radius: 12, y: 6)
711+ }
712+
713+ // MARK: le contenu, en carte qui chevauche le héros
714+
715+ private var contentCard: some View {
716+ VStack(alignment: .leading, spacing: 16) {
472 if let u = universe { 717 if let u = universe {
473 HStack { 718 HStack {
474 KAChip(text: u.wordmark, accent: u.accent) 719 KAChip(text: u.wordmark, accent: u.accent)
@@ -657,31 +902,25 @@ struct ItemDetailView: View {
657 } 902 }
658 } 903 }
659 904
660− HStack(spacing: 10) { 905+ // hypothèque estimée — l'ADN Immo·Ka (calcul local transparent)
661− if let url = it.url { 906+ if it.universeID == "immo-ka", let price = it.price, price > 25_000 {
662− Link(destination: url) { 907+ MortgageCard(price: price, accent: accent)
663− Label("Voir à la source", systemImage: "arrow.up.right.square") 908+ }
664− .font(.headline) 909+
665− .frame(maxWidth: .infinity).padding(.vertical, 14) 910+ if let la = it.latitude, let lo = it.longitude {
666− .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) 911+ Button {
667− .foregroundStyle(.white) 912+ Haptics.tap()
668− } 913+ let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))
669− } 914+ place.name = it.title
670− if let la = it.latitude, let lo = it.longitude { 915+ place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
671− Button { 916+ } label: {
672− Haptics.tap() 917+ Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
673− let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo))) 918+ .font(.headline)
674− place.name = it.title 919+ .frame(maxWidth: .infinity).padding(.vertical, 13)
675− place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault]) 920+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
676− } label: { 921+ .foregroundStyle(KATheme.lime)
677− Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
678− .font(.headline)
679− .padding(.horizontal, 16).padding(.vertical, 14)
680− .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
681− .foregroundStyle(KATheme.lime)
682− }
683− .accessibilityLabel("Itinéraire vers \(it.title)")
684 } 922 }
923+ .accessibilityLabel("Itinéraire vers \(it.title)")
685 } 924 }
686 if let src = it.url?.host() { 925 if let src = it.url?.host() {
687 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.") 926 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")
@@ -718,52 +957,20 @@ struct ItemDetailView: View {
718 } 957 }
719 } 958 }
720 } 959 }
721− }
722− .padding(16)
723 } 960 }
724− .background(KATheme.paper(scheme)) 961+ .padding(16)
725− .navigationBarTitleDisplayMode(.inline) 962+ .padding(.top, it.imageURLs.isEmpty ? 0 : 6)
726− .toolbar { 963+ .frame(maxWidth: .infinity, alignment: .leading)
727− ToolbarItemGroup(placement: .topBarTrailing) { 964+ .background(KATheme.paper(scheme),
728− Button { 965+ in: UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26,
729− if favorites.isFavorite(item) { favorites.toggle(item) } 966+ style: .continuous))
730− else if favorites.collections.count > 1 { showCollections = true } 967+ .overlay(alignment: .top) {
731− else { favorites.toggle(item) } 968+ // poignée discrète, façon feuille
732− } label: { 969+ if !it.imageURLs.isEmpty {
733− Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart") 970+ Capsule().fill(.tertiary).frame(width: 38, height: 4).padding(.top, 8)
734− .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)
735− }
736− .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")
737− if let url = it.url {
738− ShareLink(item: url, subject: Text(it.title))
739− }
740− }
741− }
742− .task {
743− recents.record(item)
744− // fiche complète depuis l'API de détail du site (galerie entière,
745− // description longue, inclusions, estimation Vrai-Prix, heures…)
746− if let u = universe, enriched == nil,
747− let full = await DetailService.enrich(item, universe: u) {
748− withAnimation(.snappy) { enriched = full }
749− }
750− if it.universeID == "resto-ka" {
751− let uid = String(it.id.dropFirst("resto-ka:".count))
752− menu = await RestoMenuLoader.load(uid: uid)
753− }
754− if let u = universe, u.map != nil, similar.isEmpty {
755− let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? []
756− similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 }
757− }
758− }
759− .fullScreenCover(item: $fullScreenImage) { img in
760− ZoomableImageView(url: img, accent: universe?.accent ?? .gray)
761− }
762− .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {
763− ForEach(favorites.collections) { c in
764− Button(c.name) { favorites.toggle(item, in: c.id) }
765 } 971 }
766 } 972 }
767− .tint(universe?.accent) 973+ .offset(y: it.imageURLs.isEmpty ? 0 : -26)
974+ .padding(.bottom, it.imageURLs.isEmpty ? 0 : -26)
768 } 975 }
769 } 976 }
modified KATests/KATests.swift +130 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
modified project.yml +2 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme