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>
17 changed files +1,525 −248
modified
KA/App/KAApp.swift
+21 −14
@@ -52,25 +52,32 @@ struct RootView: View { | ||
| 52 | 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 | 60 | var body: some View { |
| 56 | − ZStack { | |
| 57 | − pane(0) { HomeView() } | |
| 58 | − pane(1) { SearchView() } | |
| 59 | − pane(2) { UniversesView() } | |
| 60 | − pane(3) { MapTab() } | |
| 61 | − pane(4) { TrajetView().environmentObject(trajet) } | |
| 62 | − pane(5) { MoreTab() } | |
| 63 | − } | |
| 64 | − .tint(KATheme.green) | |
| 65 | − .safeAreaInset(edge: .bottom) { | |
| 66 | − // le dock s'efface dans les modes immersifs de Trajet | |
| 67 | − // (conduite et Découvrir plein écran) | |
| 68 | − if !trajet.navigating && !trajet.discovering { | |
| 61 | + // le dock occupe SON espace, sous le contenu : rien ne passe jamais | |
| 62 | + // dessous, sur aucune page — c'est une vraie barre, pas un flotteur | |
| 63 | + VStack(spacing: 0) { | |
| 64 | + ZStack { | |
| 65 | + pane(0) { HomeView() } | |
| 66 | + pane(1) { SearchView() } | |
| 67 | + pane(2) { UniversesView() } | |
| 68 | + pane(3) { MapTab() } | |
| 69 | + pane(4) { TrajetView().environmentObject(trajet) } | |
| 70 | + pane(5) { MoreTab() } | |
| 71 | + } | |
| 72 | + if dockVisible { | |
| 69 | 73 | KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true } |
| 70 | − .padding(.bottom, 4) | |
| 74 | + .padding(.top, 8) | |
| 75 | + .padding(.bottom, 2) | |
| 71 | 76 | .transition(.move(edge: .bottom).combined(with: .opacity)) |
| 72 | 77 | } |
| 73 | 78 | } |
| 79 | + .background(KATheme.paper(scheme).ignoresSafeArea()) | |
| 80 | + .tint(KATheme.green) | |
| 74 | 81 | .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating) |
| 75 | 82 | .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.discovering) |
| 76 | 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 | 74 | var tags: [String]? = nil |
| 75 | 75 | /// Résumé « en bref » de la fiche (digest du site) |
| 76 | 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 | 83 | struct Fact: Hashable, Codable { var label: String; var value: String } |
| 79 | 84 | } |
@@ -160,7 +165,7 @@ enum Ecosystem { | ||
| 160 | 165 | map: { o in |
| 161 | 166 | var it = base(o, universe: "lou-ka") |
| 162 | 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 | 169 | it.facts = [ |
| 165 | 170 | o.str("unit_type").map { .init(label: "Taille", value: $0) }, |
| 166 | 171 | o.str("address").map { .init(label: "Adresse", value: $0) }, |
@@ -178,7 +183,7 @@ enum Ecosystem { | ||
| 178 | 183 | map: { o in |
| 179 | 184 | var it = base(o, universe: "immo-ka") |
| 180 | 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 | 187 | it.facts = [ |
| 183 | 188 | o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) }, |
| 184 | 189 | o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) }, |
@@ -202,6 +207,7 @@ enum Ecosystem { | ||
| 202 | 207 | var it = base(o, universe: "auto-ka") |
| 203 | 208 | it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ") |
| 204 | 209 | it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0) |
| 210 | + it.price = o.num("price") | |
| 205 | 211 | it.facts = [ |
| 206 | 212 | o.str("make").map { .init(label: "Marque", value: $0) }, |
| 207 | 213 | o.str("model").map { .init(label: "Modèle", value: $0) }, |
@@ -223,6 +229,7 @@ enum Ecosystem { | ||
| 223 | 229 | var it = base(o, universe: "fabri-ka") |
| 224 | 230 | it.subtitle = o.str("store_id", "store") |
| 225 | 231 | it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2) |
| 232 | + it.price = o.num("price") | |
| 226 | 233 | it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 } |
| 227 | 234 | return it |
| 228 | 235 | }), |
@@ -239,6 +246,7 @@ enum Ecosystem { | ||
| 239 | 246 | if onSale, let reg = o.num("regular_price"), reg > p { |
| 240 | 247 | it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))" |
| 241 | 248 | } else { it.priceLabel = p.money2 } |
| 249 | + it.price = p | |
| 242 | 250 | } |
| 243 | 251 | it.facts = [ |
| 244 | 252 | o.str("category").map { .init(label: "Catégorie", value: $0) }, |
@@ -269,6 +277,7 @@ enum Ecosystem { | ||
| 269 | 277 | statTotalKeys: ["total_active", "total"], |
| 270 | 278 | map: { o in |
| 271 | 279 | var it = base(o, universe: "sorti-ka") |
| 280 | + it.date = o.str("start_date") | |
| 272 | 281 | it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ") |
| 273 | 282 | it.facts = [ |
| 274 | 283 | o.str("start_date").map { .init(label: "Début", value: $0) }, |
@@ -315,6 +324,7 @@ enum Ecosystem { | ||
| 315 | 324 | let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max") |
| 316 | 325 | if let a = sMin, a > 20000 { |
| 317 | 326 | it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an" |
| 327 | + it.price = a | |
| 318 | 328 | } else if let h = o.num("salary_hour_min"), h > 10 { |
| 319 | 329 | it.priceLabel = h.money2 + "/h" |
| 320 | 330 | } |
modified
KA/Core/FavoritesStore.swift
+2 −0
@@ -54,9 +54,11 @@ final class FavoritesStore: ObservableObject { | ||
| 54 | 54 | for i in collections.indices { |
| 55 | 55 | collections[i].items.removeAll { $0.id == item.id } |
| 56 | 56 | } |
| 57 | + PersonalizationStore.shared.record(.unfavorite, item: item) | |
| 57 | 58 | } else { |
| 58 | 59 | let idx = collections.firstIndex { $0.id == collectionID } ?? 0 |
| 59 | 60 | collections[idx].items.insert(item, at: 0) |
| 61 | + PersonalizationStore.shared.record(.favorite, item: item) | |
| 60 | 62 | } |
| 61 | 63 | Haptics.tap() |
| 62 | 64 | } |
modified
KA/Core/KAFilters.swift
+107 −1
@@ -38,17 +38,20 @@ enum FilterCatalog { | ||
| 38 | 38 | ] |
| 39 | 39 | case "job-ka": return [ |
| 40 | 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 | 43 | case "food-ka": return [ |
| 43 | 44 | .init(id: "on_sale", label: "En solde 🏷️", kind: .toggle(value: "true")), |
| 44 | 45 | ] |
| 45 | 46 | case "resto-ka": return [ |
| 46 | 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 | 51 | case "sorti-ka": return [ |
| 50 | 52 | .init(id: "city", label: "Ville", kind: .text(placeholder: "Sherbrooke…")), |
| 51 | 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 | 55 | .init(id: "free", label: "Gratuit 🎉", kind: .toggle(value: "true")), |
| 53 | 56 | ] |
| 54 | 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 | 165 | // MARK: - Barre + feuille de filtres |
| 60 | 166 | |
| 61 | 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 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 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 | 5 | import SwiftUI |
| 5 | 6 | |
| 6 | 7 | struct FavoritesView: View { |
| 7 | 8 | @EnvironmentObject private var favorites: FavoritesStore |
| 8 | 9 | @State private var newName = "" |
| 9 | 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 | 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 | 47 | var body: some View { |
| 13 | − NavigationStack { | |
| 14 | − ScrollView { | |
| 15 | − VStack(alignment: .leading, spacing: 18) { | |
| 16 | − if favorites.allItems.isEmpty { | |
| 17 | − KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant", | |
| 18 | − message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.") | |
| 19 | − } | |
| 20 | − ForEach(favorites.collections) { collection in | |
| 21 | − collectionSection(collection) | |
| 22 | − } | |
| 48 | + ScrollView { | |
| 49 | + VStack(alignment: .leading, spacing: 18) { | |
| 50 | + if favorites.allItems.isEmpty { | |
| 51 | + 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.") | |
| 53 | + } else { | |
| 54 | + universeChips | |
| 55 | + } | |
| 56 | + ForEach(favorites.collections) { collection in | |
| 57 | + collectionSection(collection) | |
| 23 | 58 | } |
| 24 | − .padding(16) | |
| 25 | 59 | } |
| 26 | − .background(KATheme.paper(scheme)) | |
| 27 | − .navigationTitle("Favoris") | |
| 28 | − .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 29 | − .toolbar { | |
| 30 | − ToolbarItem(placement: .topBarTrailing) { | |
| 31 | − Button { showNew = true } label: { Image(systemName: "folder.badge.plus") } | |
| 32 | − .accessibilityLabel("Nouvelle collection") | |
| 60 | + .padding(16) | |
| 61 | + } | |
| 62 | + .background(KATheme.paper(scheme)) | |
| 63 | + .navigationTitle("Favoris") | |
| 64 | + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 65 | + .searchable(text: $searchText, prompt: "Chercher dans vos favoris…") | |
| 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) { | |
| 36 | − TextField("Ex. Déménagement à Val-d'Or", text: $newName) | |
| 37 | − Button("Créer") { | |
| 38 | − favorites.addCollection(newName) | |
| 39 | − newName = "" | |
| 40 | − Haptics.success() | |
| 87 | + Button("Annuler", role: .cancel) { newName = "" } | |
| 88 | + } message: { | |
| 89 | + Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.") | |
| 90 | + } | |
| 91 | + } | |
| 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 | 124 | @ViewBuilder |
| 50 | 125 | private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View { |
| 51 | − VStack(alignment: .leading, spacing: 10) { | |
| 52 | − HStack { | |
| 53 | − Label(c.name, systemImage: "folder.fill") | |
| 54 | − .font(.headline) | |
| 55 | − Spacer() | |
| 56 | − Text("\(c.items.count)") | |
| 57 | − .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 58 | − .foregroundStyle(.secondary) | |
| 59 | − if favorites.collections.count > 1 { | |
| 60 | − Menu { | |
| 61 | − Button(role: .destructive) { | |
| 62 | − favorites.removeCollection(c.id) | |
| 63 | − } label: { Label("Supprimer la collection", systemImage: "trash") } | |
| 64 | − } label: { | |
| 65 | − Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) | |
| 126 | + let items = filtered(c.items) | |
| 127 | + // une collection vidée par le filtre s'efface (sauf si aucune n'a rien) | |
| 128 | + if !items.isEmpty || (filterUniverse == nil && searchText.isEmpty) { | |
| 129 | + VStack(alignment: .leading, spacing: 10) { | |
| 130 | + HStack { | |
| 131 | + Label(c.name, systemImage: "folder.fill") | |
| 132 | + .font(.headline) | |
| 133 | + Spacer() | |
| 134 | + Text("\(items.count)") | |
| 135 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 136 | + .foregroundStyle(.secondary) | |
| 137 | + if favorites.collections.count > 1 { | |
| 138 | + Menu { | |
| 139 | + Button(role: .destructive) { | |
| 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 | − } | |
| 69 | − if c.items.isEmpty { | |
| 70 | − Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.") | |
| 71 | − .font(.caption).foregroundStyle(.tertiary) | |
| 72 | − } | |
| 73 | − ForEach(c.items) { item in | |
| 74 | − NavigationLink(value: item) { KAItemRow(item: item) } | |
| 75 | − .buttonStyle(.plain) | |
| 76 | − .contextMenu { | |
| 77 | − ForEach(favorites.collections.filter { $0.id != c.id }) { other in | |
| 78 | − Button { favorites.move(item, to: other.id) } label: { | |
| 79 | − Label("Déplacer vers \(other.name)", systemImage: "folder") | |
| 147 | + if items.isEmpty { | |
| 148 | + Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.") | |
| 149 | + .font(.caption).foregroundStyle(.tertiary) | |
| 150 | + } | |
| 151 | + ForEach(items) { item in | |
| 152 | + NavigationLink(value: item) { KAItemRow(item: item) } | |
| 153 | + .buttonStyle(.plain) | |
| 154 | + .contextMenu { | |
| 155 | + ForEach(favorites.collections.filter { $0.id != c.id }) { other in | |
| 156 | + Button { favorites.move(item, to: other.id) } label: { | |
| 157 | + 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") } } | |
| 83 | − Button(role: .destructive) { favorites.toggle(item) } label: { | |
| 84 | − Label("Retirer", systemImage: "heart.slash") | |
| 85 | − } | |
| 86 | − } | |
| 165 | + } | |
| 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 | 13 | @EnvironmentObject private var favorites: FavoritesStore |
| 14 | 14 | @EnvironmentObject private var recents: RecentsStore |
| 15 | 15 | @State private var editorialItems: [String: [KAItem]] = [:] |
| 16 | + @StateObject private var perso = PersonalizationStore.shared | |
| 17 | + @State private var forYou: [ForYouEntry] = [] | |
| 16 | 18 | @Environment(\.colorScheme) private var scheme |
| 17 | 19 | |
| 18 | 20 | var body: some View { |
@@ -20,6 +22,9 @@ struct HomeView: View { | ||
| 20 | 22 | ScrollView { |
| 21 | 23 | VStack(alignment: .leading, spacing: 24) { |
| 22 | 24 | heroCard |
| 25 | + if !forYou.isEmpty { | |
| 26 | + ForYouSection(entries: forYou) | |
| 27 | + } | |
| 23 | 28 | launcher |
| 24 | 29 | pulseSection |
| 25 | 30 | resumeStrip |
@@ -207,32 +212,35 @@ struct HomeView: View { | ||
| 207 | 212 | VStack(alignment: .leading, spacing: 10) { |
| 208 | 213 | UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green, |
| 209 | 214 | detail: "EN DIRECT") |
| 210 | − ScrollView(.horizontal, showsIndicators: false) { | |
| 211 | − HStack(spacing: 10) { | |
| 212 | − ForEach(pulse, id: \.0.id) { (u, n) in | |
| 213 | − NavigationLink(value: u.id) { | |
| 214 | − HStack(spacing: 10) { | |
| 215 | − KALogo(universe: u, size: 30) | |
| 216 | − VStack(alignment: .leading, spacing: 1) { | |
| 217 | − KACountUp(value: n, font: KAFont.display(17), color: u.accent) | |
| 218 | − Text(u.unit.uppercased()) | |
| 219 | − .font(KAFont.mono(8)) | |
| 220 | − .foregroundStyle(.secondary) | |
| 221 | − .lineLimit(1) | |
| 222 | − } | |
| 215 | + // mosaïque dashboard : les 6 plus gros compteurs, en tuiles franches | |
| 216 | + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), | |
| 217 | + GridItem(.flexible(), spacing: 12)], spacing: 12) { | |
| 218 | + ForEach(pulse.prefix(6), id: \.0.id) { (u, n) in | |
| 219 | + NavigationLink(value: u.id) { | |
| 220 | + VStack(alignment: .leading, spacing: 6) { | |
| 221 | + HStack { | |
| 222 | + KALogo(universe: u, size: 26) | |
| 223 | + Spacer() | |
| 224 | + Circle().fill(u.accent).frame(width: 7, height: 7) | |
| 223 | 225 | } |
| 224 | − .padding(.horizontal, 13).padding(.vertical, 10) | |
| 225 | − .kaCard(accent: u.accent) | |
| 226 | + KACountUp(value: n, font: KAFont.display(21), color: 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) | |
| 228 | − .kaScrollPop() | |
| 229 | − } | |
| 230 | − if pulse.isEmpty { | |
| 231 | − Text("Le pouls de l'écosystème…") | |
| 232 | − .font(.caption).foregroundStyle(.tertiary).padding(10) | |
| 233 | + .padding(12) | |
| 234 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 235 | + .kaCard(accent: u.accent) | |
| 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 | 246 | .accessibilityLabel("Le pouls de l'écosystème en direct") |
@@ -420,7 +428,7 @@ struct HomeView: View { | ||
| 420 | 428 | |
| 421 | 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 | 432 | private var contextualUniverses: [Universe] { |
| 425 | 433 | let h = Calendar.current.component(.hour, from: Date()) |
| 426 | 434 | let weekend = Calendar.current.isDateInWeekend(Date()) |
@@ -434,6 +442,11 @@ struct HomeView: View { | ||
| 434 | 442 | } |
| 435 | 443 | let favs = favUniversesRaw.split(separator: ",").map(String.init) |
| 436 | 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 | 450 | return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil } |
| 438 | 451 | } |
| 439 | 452 | |
@@ -455,6 +468,14 @@ struct HomeView: View { | ||
| 455 | 468 | return items.isEmpty ? nil : (u, items) |
| 456 | 469 | } |
| 457 | 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 | 479 | // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil |
| 459 | 480 | var counts: [(Universe, Int)] = [] |
| 460 | 481 | await withTaskGroup(of: (String, Int?).self) { group in |
modified
KA/Features/MapView.swift
+42 −2
@@ -57,7 +57,11 @@ struct UnifiedMapView: View { | ||
| 57 | 57 | center: .init(latitude: 46.81, longitude: -71.21), |
| 58 | 58 | span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) |
| 59 | 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 | 65 | @State private var loading = false |
| 62 | 66 | @State private var zoneDirty = false |
| 63 | 67 | @State private var selectedCluster: MapCluster? |
@@ -77,9 +81,26 @@ struct UnifiedMapView: View { | ||
| 77 | 81 | private var vpZoomedEnough: Bool { |
| 78 | 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 | 100 | private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) } |
| 82 | 101 | |
| 102 | + private func persistLayers() { layersRaw = enabled.sorted().joined(separator: ",") } | |
| 103 | + | |
| 83 | 104 | var body: some View { |
| 84 | 105 | NavigationStack { |
| 85 | 106 | ZStack(alignment: .bottom) { |
@@ -130,6 +151,8 @@ struct UnifiedMapView: View { | ||
| 130 | 151 | .presentationBackgroundInteraction(.enabled(upThrough: .medium)) |
| 131 | 152 | } |
| 132 | 153 | .task { |
| 154 | + // restaure les couches choisies à la dernière session | |
| 155 | + enabled = Set(layersRaw.split(separator: ",").map(String.init)) | |
| 133 | 156 | // pilotage debug : couche Vrai-Prix active + zoom quartier |
| 134 | 157 | // (defaults write com.groupeka.ka ka.debug.mapvp -bool true) |
| 135 | 158 | if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") { |
@@ -190,11 +213,28 @@ struct UnifiedMapView: View { | ||
| 190 | 213 | private var chips: some View { |
| 191 | 214 | ScrollView(.horizontal, showsIndicators: false) { |
| 192 | 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 | 232 | ForEach(mapUniverses) { u in |
| 194 | 233 | let on = enabled.contains(u.id) |
| 195 | 234 | Button { |
| 196 | 235 | Haptics.tap() |
| 197 | 236 | if on { enabled.remove(u.id) } else { enabled.insert(u.id) } |
| 237 | + persistLayers() | |
| 198 | 238 | selectedCluster = nil |
| 199 | 239 | Task { await loadZone() } |
| 200 | 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 | 10 | @StateObject private var kaid = KAIDManager.shared |
| 11 | 11 | @EnvironmentObject private var favorites: FavoritesStore |
| 12 | 12 | @EnvironmentObject private var recents: RecentsStore |
| 13 | + @StateObject private var perso = PersonalizationStore.shared | |
| 14 | + @AppStorage(PersonalizationStore.enabledKey) private var persoEnabled = true | |
| 13 | 15 | @State private var showHubSite = false |
| 16 | + @State private var confirmReset = false | |
| 14 | 17 | |
| 15 | 18 | private var favIDs: Set<String> { |
| 16 | 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 | 113 | Section { |
| 84 | 114 | ForEach(Ecosystem.all) { u in |
| 85 | 115 | Button { |
@@ -156,6 +186,15 @@ struct ProfileView: View { | ||
| 156 | 186 | SiteBrowserCover(title: "Groupe KA", accent: KATheme.green, |
| 157 | 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 | 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 | 39 | private var history: [String] { historyRaw.split(separator: "\n").map(String.init) } |
@@ -62,8 +63,18 @@ struct SearchView: View { | ||
| 62 | 63 | .padding(16) |
| 63 | 64 | } |
| 64 | 65 | .background(KATheme.paper(scheme)) |
| 65 | − .navigationTitle("Recherche") | |
| 66 | − .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…") | |
| 66 | + .navigationTitle("") | |
| 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 | 78 | .onSubmit(of: .search) { Task { await search() } } |
| 68 | 79 | .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } |
| 69 | 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 | 146 | private var suggestions: some View { |
| 126 | 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 | 161 | if !history.isEmpty { |
| 128 | − Text("Récemment cherché").font(.headline) | |
| 129 | − ForEach(history.prefix(6), id: \.self) { h in | |
| 130 | − Button { | |
| 131 | − query = h | |
| 132 | − Task { await search(h) } | |
| 133 | − } label: { | |
| 134 | − HStack { | |
| 135 | − Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) | |
| 136 | − Text(h) | |
| 137 | − Spacer() | |
| 162 | + UniverseSectionHeader(title: "Récemment cherché", accent: KATheme.green) | |
| 163 | + KAFlow(spacing: 8) { | |
| 164 | + ForEach(history.prefix(6), id: \.self) { h in | |
| 165 | + Button { | |
| 166 | + query = h | |
| 167 | + Task { await search(h) } | |
| 168 | + } label: { | |
| 169 | + HStack(spacing: 6) { | |
| 170 | + Image(systemName: "clock.arrow.circlepath") | |
| 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) | |
| 145 | − ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in | |
| 146 | − Button { query = s; Task { await search(s) } } label: { | |
| 147 | − HStack { | |
| 148 | − Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green) | |
| 149 | − Text(s); Spacer() | |
| 183 | + UniverseSectionHeader(title: "Idées de départ", accent: KATheme.green) | |
| 184 | + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), | |
| 185 | + GridItem(.flexible(), spacing: 12)], spacing: 12) { | |
| 186 | + ForEach(Self.ideas, id: \.query) { idea in | |
| 187 | + let u = Ecosystem.universe(idea.universeID) | |
| 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 | 249 | var hist = history.filter { $0 != q } |
| 191 | 250 | hist.insert(q, at: 0) |
| 192 | 251 | historyRaw = hist.prefix(10).joined(separator: "\n") |
| 252 | + PersonalizationStore.shared.recordSearch(q) | |
| 193 | 253 | |
| 194 | 254 | let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) } |
| 195 | 255 | var found: [String: [KAItem]] = [:] |
modified
KA/Features/UniverseShowcase.swift
+3 −1
@@ -145,9 +145,11 @@ struct UniverseShowcaseCard: View { | ||
| 145 | 145 | |
| 146 | 146 | var body: some View { |
| 147 | 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 | 150 | .frame(height: 224) |
| 150 | 151 | .frame(maxWidth: .infinity) |
| 152 | + .overlay(KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)) | |
| 151 | 153 | .clipped() |
| 152 | 154 | LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)], |
| 153 | 155 | startPoint: .top, endPoint: .bottom) |
modified
KA/Features/UniversesView.swift
+327 −120
@@ -48,25 +48,47 @@ struct UniversesView: View { | ||
| 48 | 48 | @State private var totals: [String: Int] = [:] |
| 49 | 49 | @Environment(\.colorScheme) private var scheme |
| 50 | 50 | |
| 51 | − private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)] | |
| 52 | 51 | @State private var showMap = false |
| 53 | 52 | @State private var path = NavigationPath() |
| 54 | 53 | |
| 55 | 54 | var body: some View { |
| 56 | 55 | NavigationStack(path: $path) { |
| 57 | 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 | 74 | ForEach(Ecosystem.all) { u in |
| 60 | 75 | NavigationLink(value: u.id) { |
| 61 | − UniverseCard(universe: u, total: totals[u.id]) | |
| 76 | + UniverseBand(universe: u, total: totals[u.id]) | |
| 62 | 77 | } |
| 63 | 78 | .buttonStyle(KAPressStyle()) |
| 79 | + .kaScrollPop(axis: .vertical) | |
| 64 | 80 | } |
| 65 | 81 | } |
| 66 | 82 | .padding(16) |
| 67 | 83 | } |
| 68 | − .background(KATheme.paper(scheme)) | |
| 69 | − .navigationTitle("Univers") | |
| 84 | + .background { | |
| 85 | + ZStack { | |
| 86 | + KATheme.paper(scheme) | |
| 87 | + KAAurora() | |
| 88 | + } | |
| 89 | + .ignoresSafeArea() | |
| 90 | + } | |
| 91 | + .navigationTitle("") | |
| 70 | 92 | .toolbar { |
| 71 | 93 | ToolbarItem(placement: .topBarTrailing) { |
| 72 | 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 | 128 | let universe: Universe |
| 105 | 129 | let total: Int? |
| 106 | 130 | @Environment(\.colorScheme) private var scheme |
| 107 | 131 | |
| 108 | 132 | var body: some View { |
| 109 | − VStack(alignment: .leading, spacing: 10) { | |
| 110 | − HStack { | |
| 111 | − KALogo(universe: universe, size: 40) | |
| 112 | − Spacer() | |
| 113 | − Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) | |
| 114 | − } | |
| 115 | − KAWordmark(universe: universe, size: 17) | |
| 116 | − Text(universe.tagline) | |
| 117 | − .font(.caption).foregroundStyle(KATheme.ink2(scheme)) | |
| 118 | − .lineLimit(2, reservesSpace: true) | |
| 119 | − if let total { | |
| 120 | − Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)") | |
| 121 | − .font(KAFont.mono(10)) | |
| 122 | − .foregroundStyle(universe.accent) | |
| 123 | − .contentTransition(.numericText()) | |
| 124 | − } else { | |
| 125 | − Text("—").font(KAFont.mono(10, bold: false)) | |
| 126 | − .foregroundStyle(.tertiary) | |
| 133 | + ZStack(alignment: .topTrailing) { | |
| 134 | + RoundedRectangle(cornerRadius: 18, style: .continuous) | |
| 135 | + .fill(LinearGradient( | |
| 136 | + colors: [universe.accent.opacity(scheme == .dark ? 0.30 : 0.20), | |
| 137 | + universe.accent.opacity(scheme == .dark ? 0.08 : 0.03)], | |
| 138 | + startPoint: .topTrailing, endPoint: .bottomLeading)) | |
| 139 | + .background(KATheme.surface(scheme), | |
| 140 | + in: RoundedRectangle(cornerRadius: 18, style: .continuous)) | |
| 141 | + KALogo(universe: universe, size: 120) | |
| 142 | + .opacity(scheme == .dark ? 0.14 : 0.10) | |
| 143 | + .rotationEffect(.degrees(9)) | |
| 144 | + .offset(x: 26, y: -18) | |
| 145 | + .accessibilityHidden(true) | |
| 146 | + HStack(spacing: 12) { | |
| 147 | + VStack(alignment: .leading, spacing: 5) { | |
| 148 | + KAWordmark(universe: universe, size: 23) | |
| 149 | + Text(universe.tagline) | |
| 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) | |
| 130 | − .frame(maxWidth: .infinity, alignment: .leading) | |
| 131 | − .kaCard(accent: universe.accent) | |
| 180 | + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) | |
| 181 | + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) | |
| 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 | 186 | .accessibilityElement(children: .combine) |
| 187 | + .accessibilityLabel("\(universe.name) — \(universe.tagline)") | |
| 133 | 188 | } |
| 134 | 189 | } |
| 135 | 190 | |
@@ -294,6 +349,11 @@ struct UniverseHomeView: View { | ||
| 294 | 349 | Task { await load() } |
| 295 | 350 | } |
| 296 | 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 | 404 | /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine |
| 345 | 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 | 408 | @ViewBuilder |
| 348 | 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 | 450 | let visuals = items.filter { $0.imageURL != nil } |
| 350 | 451 | if visuals.count >= 3, let showcase = visuals.first { |
| 351 | 452 | let featured = Array(visuals.dropFirst().prefix(6)) |
@@ -441,34 +542,178 @@ struct ItemDetailView: View { | ||
| 441 | 542 | /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste. |
| 442 | 543 | private var it: KAItem { enriched ?? item } |
| 443 | 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 | 562 | var body: some View { |
| 446 | 563 | ScrollView { |
| 447 | − VStack(alignment: .leading, spacing: 16) { | |
| 448 | − if !it.imageURLs.isEmpty { | |
| 449 | − TabView { | |
| 450 | − ForEach(it.imageURLs, id: \.self) { img in | |
| 451 | − KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | |
| 452 | − .onTapGesture { Haptics.tap(); fullScreenImage = img } | |
| 453 | − .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir") | |
| 454 | − .accessibilityAddTraits(.isButton) | |
| 455 | − } | |
| 456 | − } | |
| 457 | − .tabViewStyle(.page(indexDisplayMode: it.imageURLs.count > 1 ? .automatic : .never)) | |
| 458 | − .frame(height: 240) | |
| 459 | − .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) | |
| 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 | − } | |
| 564 | + VStack(alignment: .leading, spacing: 0) { | |
| 565 | + if !it.imageURLs.isEmpty { heroGallery } | |
| 566 | + contentCard | |
| 567 | + } | |
| 568 | + } | |
| 569 | + .ignoresSafeArea(edges: it.imageURLs.isEmpty ? [] : .top) | |
| 570 | + .background(KATheme.paper(scheme)) | |
| 571 | + .navigationBarTitleDisplayMode(.inline) | |
| 572 | + .toolbarBackground(it.imageURLs.isEmpty ? .automatic : .hidden, for: .navigationBar) | |
| 573 | + .toolbar { | |
| 574 | + ToolbarItem(placement: .topBarTrailing) { | |
| 575 | + if let url = it.url { | |
| 576 | + ShareLink(item: url, subject: Text(it.title)) | |
| 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 | 717 | if let u = universe { |
| 473 | 718 | HStack { |
| 474 | 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) { | |
| 661 | − if let url = it.url { | |
| 662 | − Link(destination: url) { | |
| 663 | − Label("Voir à la source", systemImage: "arrow.up.right.square") | |
| 664 | − .font(.headline) | |
| 665 | − .frame(maxWidth: .infinity).padding(.vertical, 14) | |
| 666 | − .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 667 | − .foregroundStyle(.white) | |
| 668 | − } | |
| 669 | − } | |
| 670 | − if let la = it.latitude, let lo = it.longitude { | |
| 671 | − Button { | |
| 672 | − Haptics.tap() | |
| 673 | − let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo))) | |
| 674 | − place.name = it.title | |
| 675 | − place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault]) | |
| 676 | − } label: { | |
| 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)") | |
| 905 | + // hypothèque estimée — l'ADN Immo·Ka (calcul local transparent) | |
| 906 | + if it.universeID == "immo-ka", let price = it.price, price > 25_000 { | |
| 907 | + MortgageCard(price: price, accent: accent) | |
| 908 | + } | |
| 909 | + | |
| 910 | + if let la = it.latitude, let lo = it.longitude { | |
| 911 | + Button { | |
| 912 | + Haptics.tap() | |
| 913 | + let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo))) | |
| 914 | + place.name = it.title | |
| 915 | + place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault]) | |
| 916 | + } label: { | |
| 917 | + Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill") | |
| 918 | + .font(.headline) | |
| 919 | + .frame(maxWidth: .infinity).padding(.vertical, 13) | |
| 920 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 921 | + .foregroundStyle(KATheme.lime) | |
| 684 | 922 | } |
| 923 | + .accessibilityLabel("Itinéraire vers \(it.title)") | |
| 685 | 924 | } |
| 686 | 925 | if let src = it.url?.host() { |
| 687 | 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)) | |
| 725 | − .navigationBarTitleDisplayMode(.inline) | |
| 726 | − .toolbar { | |
| 727 | − ToolbarItemGroup(placement: .topBarTrailing) { | |
| 728 | − Button { | |
| 729 | − if favorites.isFavorite(item) { favorites.toggle(item) } | |
| 730 | − else if favorites.collections.count > 1 { showCollections = true } | |
| 731 | − else { favorites.toggle(item) } | |
| 732 | − } label: { | |
| 733 | − Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart") | |
| 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) } | |
| 961 | + .padding(16) | |
| 962 | + .padding(.top, it.imageURLs.isEmpty ? 0 : 6) | |
| 963 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 964 | + .background(KATheme.paper(scheme), | |
| 965 | + in: UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26, | |
| 966 | + style: .continuous)) | |
| 967 | + .overlay(alignment: .top) { | |
| 968 | + // poignée discrète, façon feuille | |
| 969 | + if !it.imageURLs.isEmpty { | |
| 970 | + Capsule().fill(.tertiary).frame(width: 38, height: 4).padding(.top, 8) | |
| 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
@@ -155,6 +155,136 @@ final class RefonteTests: XCTestCase { | ||
| 155 | 155 | } |
| 156 | 156 | } |
| 157 | 157 | |
| 158 | +// MARK: - v3.1.0 : personnalisation, agenda Sorti·Ka, hypothèque, chips métier | |
| 159 | + | |
| 160 | +final class PersonalizationTests: XCTestCase { | |
| 161 | + private func event(_ kind: InteractionKind, u: String? = "lou-ka", city: String? = nil, | |
| 162 | + price: Double? = nil, tags: [String] = [], query: String? = nil, | |
| 163 | + daysAgo: Double = 0) -> InteractionEvent { | |
| 164 | + InteractionEvent(kind: kind, universeID: u, city: city, price: price, | |
| 165 | + tags: tags, query: query, | |
| 166 | + date: Date().addingTimeInterval(-daysAgo * 86_400)) | |
| 167 | + } | |
| 168 | + | |
| 169 | + func testAffinityFavoriteBeatsView() { | |
| 170 | + let events = [event(.view, u: "lou-ka"), event(.favorite, u: "resto-ka")] | |
| 171 | + let p = PreferenceEngine.computeProfile(events) | |
| 172 | + XCTAssertEqual(p.topUniverses(1).first, "resto-ka", "un favori pèse plus qu'une vue") | |
| 173 | + XCTAssertEqual(p.affinity["resto-ka"] ?? 0, 1.0, accuracy: 0.001, "affinité normalisée à 1") | |
| 174 | + } | |
| 175 | + | |
| 176 | + func testDecayOldSignalsFade() { | |
| 177 | + let events = [event(.view, u: "auto-ka", daysAgo: 60), event(.view, u: "lou-ka", daysAgo: 0)] | |
| 178 | + let p = PreferenceEngine.computeProfile(events) | |
| 179 | + XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le signal frais domine l'ancien") | |
| 180 | + } | |
| 181 | + | |
| 182 | + func testUnfavoriteIsNegative() { | |
| 183 | + let events = [event(.favorite, u: "job-ka"), event(.unfavorite, u: "job-ka"), | |
| 184 | + event(.view, u: "lou-ka")] | |
| 185 | + let p = PreferenceEngine.computeProfile(events) | |
| 186 | + XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le retrait annule le favori") | |
| 187 | + } | |
| 188 | + | |
| 189 | + func testHasSignalsThreshold() { | |
| 190 | + let few = PreferenceEngine.computeProfile([event(.view)]) | |
| 191 | + XCTAssertFalse(few.hasSignals, "pas de fausse personnalisation avec 1 geste") | |
| 192 | + let enough = PreferenceEngine.computeProfile((0..<6).map { _ in event(.view, city: "Québec") }) | |
| 193 | + XCTAssertTrue(enough.hasSignals) | |
| 194 | + XCTAssertEqual(enough.topCities.first, "Québec") | |
| 195 | + } | |
| 196 | + | |
| 197 | + func testScorePrefersCityAndPriceBand() { | |
| 198 | + let events = (0..<6).map { _ in event(.view, u: "lou-ka", city: "Québec", price: 1200) } | |
| 199 | + let p = PreferenceEngine.computeProfile(events) | |
| 200 | + func it(_ id: String, city: String?, price: Double?) -> KAItem { | |
| 201 | + KAItem(id: id, universeID: "lou-ka", title: "t", subtitle: nil, priceLabel: nil, | |
| 202 | + city: city, url: nil, imageURL: nil, latitude: nil, longitude: nil, | |
| 203 | + detail: nil, facts: [], price: price) | |
| 204 | + } | |
| 205 | + let match = PreferenceEngine.score(it("a", city: "Québec", price: 1250), profile: p) | |
| 206 | + let off = PreferenceEngine.score(it("b", city: "Gatineau", price: 3900), profile: p) | |
| 207 | + XCTAssertGreaterThan(match, off, "ville + gamme de prix doivent booster le score") | |
| 208 | + } | |
| 209 | + | |
| 210 | + func testQueriesFeedTags() { | |
| 211 | + let events = (0..<5).map { _ in event(.search, u: nil, query: "condo vieux montréal") } | |
| 212 | + + [event(.view, u: "immo-ka")] | |
| 213 | + let p = PreferenceEngine.computeProfile(events) | |
| 214 | + XCTAssertTrue(p.topTags.contains("condo"), "les mots de recherche nourrissent les tags") | |
| 215 | + } | |
| 216 | +} | |
| 217 | + | |
| 218 | +final class EventBucketTests: XCTestCase { | |
| 219 | + private let fmt: DateFormatter = { | |
| 220 | + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f | |
| 221 | + }() | |
| 222 | + private func iso(_ daysFromNow: Int) -> String { | |
| 223 | + fmt.string(from: Calendar.current.date(byAdding: .day, value: daysFromNow, to: .now)!) | |
| 224 | + } | |
| 225 | + | |
| 226 | + func testBuckets() { | |
| 227 | + XCTAssertEqual(EventBucket.bucket(iso: iso(0)), .today) | |
| 228 | + XCTAssertEqual(EventBucket.bucket(iso: iso(3)), .thisWeek) | |
| 229 | + XCTAssertEqual(EventBucket.bucket(iso: iso(20)), .thisMonth) | |
| 230 | + XCTAssertEqual(EventBucket.bucket(iso: iso(90)), .later) | |
| 231 | + XCTAssertEqual(EventBucket.bucket(iso: iso(-10)), .ongoing, "commencé mais actif (upcoming)") | |
| 232 | + XCTAssertEqual(EventBucket.bucket(iso: nil), .undated) | |
| 233 | + XCTAssertEqual(EventBucket.bucket(iso: "n'importe quoi"), .undated) | |
| 234 | + } | |
| 235 | + | |
| 236 | + func testBucketOrderIsChronological() { | |
| 237 | + XCTAssertLessThan(EventBucket.today, EventBucket.thisWeek) | |
| 238 | + XCTAssertLessThan(EventBucket.thisWeek, EventBucket.thisMonth) | |
| 239 | + XCTAssertLessThan(EventBucket.thisMonth, EventBucket.later) | |
| 240 | + } | |
| 241 | +} | |
| 242 | + | |
| 243 | +final class MortgageTests: XCTestCase { | |
| 244 | + func testMonthlyPaymentKnownValue() { | |
| 245 | + // 400 000 $, 20 % de mise de fonds, 5 %/an, 25 ans → ≈ 1 870,68 $/mois | |
| 246 | + let m = MortgageMath.monthlyPayment(price: 400_000, downFraction: 0.20, | |
| 247 | + annualRate: 5, years: 25) | |
| 248 | + XCTAssertEqual(m, 1870.68, accuracy: 1.0) | |
| 249 | + } | |
| 250 | + | |
| 251 | + func testZeroRateIsLinear() { | |
| 252 | + let m = MortgageMath.monthlyPayment(price: 120_000, downFraction: 0, | |
| 253 | + annualRate: 0, years: 10) | |
| 254 | + XCTAssertEqual(m, 1000, accuracy: 0.01) | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +final class QuickFilterTests: XCTestCase { | |
| 259 | + func testChipsExistForCoreUniverses() { | |
| 260 | + for id in ["lou-ka", "immo-ka", "auto-ka", "resto-ka", "sorti-ka", "job-ka"] { | |
| 261 | + XCTAssertFalse(QuickFilterCatalog.chips(for: id).isEmpty, "chips manquantes : \(id)") | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + func testRestoCuisineSlugsAreRealAPIValues() { | |
| 266 | + // slugs vérifiés live le 2026-08-26 (cuisine=sushi retournait 0) | |
| 267 | + let valid: Set<String> = ["autre", "bbq-grillades", "burgers", "cafe-dessert", | |
| 268 | + "chinois", "dejeuner-brunch", "fruits-de-mer", "indien", | |
| 269 | + "italien", "mexicain", "pizza", "poulet", "quebecois", | |
| 270 | + "sushi-japonais", "vegetarien-vegan"] | |
| 271 | + for chip in QuickFilterCatalog.chips(for: "resto-ka") { | |
| 272 | + if let c = chip.params["cuisine"] { | |
| 273 | + XCTAssertTrue(valid.contains(c), "slug de cuisine inconnu de l'API : \(c)") | |
| 274 | + } | |
| 275 | + } | |
| 276 | + } | |
| 277 | + | |
| 278 | + func testOldFavoritesJSONStillDecodes() { | |
| 279 | + // un favori sérialisé AVANT l'ajout de price/date doit rester lisible | |
| 280 | + let old = #"{"id":"lou-ka:1","universeID":"lou-ka","title":"4½","imageURLs":[],"facts":[],"links":[]}"# | |
| 281 | + let item = try? JSONDecoder().decode(KAItem.self, from: Data(old.utf8)) | |
| 282 | + XCTAssertNotNil(item) | |
| 283 | + XCTAssertNil(item?.price) | |
| 284 | + XCTAssertNil(item?.date) | |
| 285 | + } | |
| 286 | +} | |
| 287 | + | |
| 158 | 288 | // MARK: - v2.0.0 : logos officiels + sites intégrés |
| 159 | 289 | |
| 160 | 290 | final class SitesLogosTests: XCTestCase { |
modified
project.yml
+2 −2
@@ -9,8 +9,8 @@ options: | ||
| 9 | 9 | settings: |
| 10 | 10 | base: |
| 11 | 11 | SWIFT_VERSION: "5.0" |
| 12 | − MARKETING_VERSION: "3.0.0" | |
| 13 | − CURRENT_PROJECT_VERSION: "13" | |
| 12 | + MARKETING_VERSION: "3.1.0" | |
| 13 | + CURRENT_PROJECT_VERSION: "14" | |
| 14 | 14 | DEVELOPMENT_TEAM: "3YM54G49SN" |
| 15 | 15 | CODE_SIGN_STYLE: Automatic |
| 16 | 16 | GENERATE_INFOPLIST_FILE: true |
| 17 | 17 | |