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