v1.0.0 (6) REFONTE MAJEURE — carte pièce maîtresse (pilules de prix, clustering grille, recherche par zone bbox réelle, fiche compacte, synchro liste/carte), galeries multi-photos plein écran zoomables, itinéraire, semblables, tri + recherches sauvegardées avec ALERTES honnêtes (+N recomptés à l ouverture), historique consulté, collections éditoriales réelles, accueil Reprendre, nav Accueil/Recherche/Explorer/Carte/Profil, hub Profil, 12 tests verts, audit docs/AUDIT-REFONTE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 changed files +1,013 −81
modified
KA/App/KAApp.swift
+15 −5
@@ -25,6 +25,7 @@ struct KAApp: App { | ||
| 25 | 25 | } |
| 26 | 26 | } |
| 27 | 27 | .environmentObject(favorites) |
| 28 | + .environmentObject(RecentsStore.shared) | |
| 28 | 29 | .preferredColorScheme(appearance == "clair" ? .light : appearance == "sombre" ? .dark : nil) |
| 29 | 30 | } |
| 30 | 31 | } |
@@ -35,6 +36,7 @@ struct KAApp: App { | ||
| 35 | 36 | struct RootView: View { |
| 36 | 37 | @State private var showAgent = false |
| 37 | 38 | @State private var tab = 0 |
| 39 | + @StateObject private var recents = RecentsStore.shared | |
| 38 | 40 | |
| 39 | 41 | var body: some View { |
| 40 | 42 | ZStack(alignment: .bottomTrailing) { |
@@ -44,13 +46,15 @@ struct RootView: View { | ||
| 44 | 46 | SearchView() |
| 45 | 47 | .tabItem { Label("Recherche", systemImage: "magnifyingglass") }.tag(1) |
| 46 | 48 | UniversesView() |
| 47 | − .tabItem { Label("Univers", systemImage: "circle.grid.3x3.fill") }.tag(2) | |
| 48 | − FavoritesView() | |
| 49 | − .tabItem { Label("Favoris", systemImage: "heart.fill") }.tag(3) | |
| 49 | + .tabItem { Label("Explorer", systemImage: "circle.grid.3x3.fill") }.tag(2) | |
| 50 | + MapTab() | |
| 51 | + .tabItem { Label("Carte", systemImage: "map.fill") }.tag(3) | |
| 50 | 52 | MoreTab() |
| 51 | 53 | .tabItem { Label("Profil", systemImage: "person.crop.circle") }.tag(4) |
| 54 | + .badge(recents.alertCount) | |
| 52 | 55 | } |
| 53 | 56 | .tint(KATheme.green) |
| 57 | + .task { await recents.refreshAlerts() } | |
| 54 | 58 | |
| 55 | 59 | // Bulle KA Agent — flottante au-dessus de la tab bar, partout |
| 56 | 60 | Button { |
@@ -76,8 +80,14 @@ struct RootView: View { | ||
| 76 | 80 | } |
| 77 | 81 | } |
| 78 | 82 | |
| 79 | −/// Onglet Profil (contient aussi Favoris pour garder 5 onglets nets — les | |
| 80 | −/// favoris ont leur raccourci au sommet). | |
| 83 | +/// Onglet Carte : la carte unifiée directement (pas de fermeture — c'est un onglet) | |
| 84 | +struct MapTab: View { | |
| 85 | + var body: some View { | |
| 86 | + UnifiedMapView(embedded: true) | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +/// Onglet Profil — inclut Favoris, Historique et Recherches sauvegardées. | |
| 81 | 91 | struct MoreTab: View { |
| 82 | 92 | var body: some View { |
| 83 | 93 | ProfileView() |
modified
KA/Core/Ecosystem.swift
+13 −7
@@ -58,6 +58,8 @@ struct KAItem: Identifiable, Hashable, Codable { | ||
| 58 | 58 | var city: String? |
| 59 | 59 | var url: URL? |
| 60 | 60 | var imageURL: URL? |
| 61 | + /// Galerie complète (les sites en ont souvent plusieurs) | |
| 62 | + var imageURLs: [URL] = [] | |
| 61 | 63 | var latitude: Double? |
| 62 | 64 | var longitude: Double? |
| 63 | 65 | /// Description longue (fiche) |
@@ -115,14 +117,17 @@ enum Ecosystem { | ||
| 115 | 117 | private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem { |
| 116 | 118 | var id: String? |
| 117 | 119 | for k in idKeys { if let v = o[k]?.text { id = v; break } } |
| 118 | − // première image utilisable : champ `images` (liste) ou `image` | |
| 119 | − var image: URL? | |
| 120 | − if let arr = o["images"]?.array { | |
| 121 | − image = arr.compactMap { $0.text }.first(where: { $0.hasPrefix("http") }).flatMap(URL.init(string:)) | |
| 122 | − } | |
| 123 | − if image == nil { | |
| 124 | − image = o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)) | |
| 120 | + // galerie : champ `images` (liste) ou `image` — jusqu'à 10, http seulement | |
| 121 | + var gallery: [URL] = (o["images"]?.array ?? []) | |
| 122 | + .compactMap { $0.text } | |
| 123 | + .filter { $0.hasPrefix("http") } | |
| 124 | + .prefix(10) | |
| 125 | + .compactMap(URL.init(string:)) | |
| 126 | + if gallery.isEmpty, | |
| 127 | + let single = o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)) { | |
| 128 | + gallery = [single] | |
| 125 | 129 | } |
| 130 | + let image = gallery.first | |
| 126 | 131 | return KAItem( |
| 127 | 132 | id: "\(universe):\(id ?? UUID().uuidString)", |
| 128 | 133 | universeID: universe, |
@@ -131,6 +136,7 @@ enum Ecosystem { | ||
| 131 | 136 | city: o.str("city"), |
| 132 | 137 | url: o.str("url").flatMap(URL.init(string:)), |
| 133 | 138 | imageURL: image, |
| 139 | + imageURLs: gallery, | |
| 134 | 140 | latitude: o.num("lat", "latitude"), |
| 135 | 141 | longitude: o.num("lng", "lon", "longitude"), |
| 136 | 142 | detail: o.str("description", "menu_summary", "bio").map { $0.strippingHTML.trimmingCharacters(in: .whitespacesAndNewlines) }, |
added
KA/Core/RecentsStore.swift
+141 −0
@@ -0,0 +1,141 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// RecentsStore.swift — historique des éléments consultés, recherches | |
| 3 | +// sauvegardées et ALERTES honnêtes : à l'ouverture, on recompte le total réel | |
| 4 | +// de chaque recherche sauvegardée via l'API et on affiche le delta (« +12 | |
| 5 | +// nouveautés ») — jamais de faux chiffre. Persistance JSON locale. | |
| 6 | +import Foundation | |
| 7 | +import SwiftUI | |
| 8 | + | |
| 9 | +struct SavedSearch: Identifiable, Codable, Hashable { | |
| 10 | + var id: UUID = UUID() | |
| 11 | + var name: String | |
| 12 | + var universeID: String // "tous" = recherche universelle | |
| 13 | + var query: String | |
| 14 | + var params: [String: String] = [:] | |
| 15 | + var alertsOn: Bool = true | |
| 16 | + var lastTotal: Int? | |
| 17 | + var newCount: Int = 0 | |
| 18 | + var lastChecked: Date? | |
| 19 | +} | |
| 20 | + | |
| 21 | +@MainActor | |
| 22 | +final class RecentsStore: ObservableObject { | |
| 23 | + static let shared = RecentsStore() | |
| 24 | + | |
| 25 | + @Published private(set) var viewed: [KAItem] = [] { didSet { save() } } | |
| 26 | + @Published var savedSearches: [SavedSearch] = [] { didSet { save() } } | |
| 27 | + | |
| 28 | + var alertCount: Int { savedSearches.filter(\.alertsOn).map(\.newCount).reduce(0, +) } | |
| 29 | + | |
| 30 | + private var fileURL: URL { | |
| 31 | + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 32 | + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 33 | + return dir.appendingPathComponent("ka-recents.json") | |
| 34 | + } | |
| 35 | + | |
| 36 | + private struct Snapshot: Codable { | |
| 37 | + var viewed: [KAItem] | |
| 38 | + var searches: [SavedSearch] | |
| 39 | + } | |
| 40 | + | |
| 41 | + init() { | |
| 42 | + if let data = try? Data(contentsOf: fileURL), | |
| 43 | + let s = try? JSONDecoder().decode(Snapshot.self, from: data) { | |
| 44 | + viewed = s.viewed | |
| 45 | + savedSearches = s.searches | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + private func save() { | |
| 50 | + if let data = try? JSONEncoder().encode(Snapshot(viewed: viewed, searches: savedSearches)) { | |
| 51 | + try? data.write(to: fileURL, options: .atomic) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + // MARK: historique consulté | |
| 56 | + | |
| 57 | + func record(_ item: KAItem) { | |
| 58 | + viewed.removeAll { $0.id == item.id } | |
| 59 | + viewed.insert(item, at: 0) | |
| 60 | + if viewed.count > 30 { viewed.removeLast(viewed.count - 30) } | |
| 61 | + } | |
| 62 | + | |
| 63 | + func clearHistory() { viewed = [] } | |
| 64 | + | |
| 65 | + // MARK: recherches sauvegardées + alertes | |
| 66 | + | |
| 67 | + func saveSearch(name: String, universeID: String, query: String, params: [String: String]) { | |
| 68 | + var s = SavedSearch(name: name.isEmpty ? query : name, | |
| 69 | + universeID: universeID, query: query, params: params) | |
| 70 | + savedSearches.insert(s, at: 0) | |
| 71 | + Haptics.success() | |
| 72 | + // total initial en arrière-plan | |
| 73 | + Task { [weak self] in | |
| 74 | + guard let self else { return } | |
| 75 | + if let u = Ecosystem.universe(universeID) { | |
| 76 | + s.lastTotal = await UniverseService.total(u, query: query, params: params) | |
| 77 | + s.lastChecked = .now | |
| 78 | + if let i = self.savedSearches.firstIndex(where: { $0.id == s.id }) { | |
| 79 | + self.savedSearches[i] = s | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + func remove(_ id: UUID) { savedSearches.removeAll { $0.id == id } } | |
| 86 | + | |
| 87 | + func markSeen(_ id: UUID) { | |
| 88 | + guard let i = savedSearches.firstIndex(where: { $0.id == id }) else { return } | |
| 89 | + savedSearches[i].lastTotal = (savedSearches[i].lastTotal ?? 0) + savedSearches[i].newCount | |
| 90 | + savedSearches[i].newCount = 0 | |
| 91 | + } | |
| 92 | + | |
| 93 | + /// Recompte chaque recherche (deltas réels). Appelé à l'ouverture de l'app. | |
| 94 | + func refreshAlerts() async { | |
| 95 | + for idx in savedSearches.indices { | |
| 96 | + let s = savedSearches[idx] | |
| 97 | + guard s.alertsOn, let u = Ecosystem.universe(s.universeID) else { continue } | |
| 98 | + if let total = await UniverseService.total(u, query: s.query, params: s.params) { | |
| 99 | + if let last = s.lastTotal, total > last { | |
| 100 | + savedSearches[idx].newCount = total - last | |
| 101 | + } else if s.lastTotal == nil { | |
| 102 | + savedSearches[idx].lastTotal = total | |
| 103 | + } | |
| 104 | + savedSearches[idx].lastChecked = .now | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +// MARK: - Collections éditoriales (requêtes RÉELLES, aucun contenu inventé) | |
| 111 | + | |
| 112 | +struct EditorialCollection: Identifiable { | |
| 113 | + let id: String | |
| 114 | + let title: String | |
| 115 | + let subtitle: String | |
| 116 | + let symbol: String | |
| 117 | + let universeID: String | |
| 118 | + let query: String? | |
| 119 | + let params: [String: String] | |
| 120 | +} | |
| 121 | + | |
| 122 | +enum Editorial { | |
| 123 | + static let collections: [EditorialCollection] = [ | |
| 124 | + .init(id: "week-end", title: "Le week-end s'organise", | |
| 125 | + subtitle: "Sorties gratuites à venir partout au Québec", | |
| 126 | + symbol: "party.popper.fill", universeID: "sorti-ka", | |
| 127 | + query: nil, params: ["free": "true"]), | |
| 128 | + .init(id: "aubaines-epicerie", title: "Le panier futé", | |
| 129 | + subtitle: "Les soldes d'épicerie du moment", | |
| 130 | + symbol: "tag.fill", universeID: "food-ka", | |
| 131 | + query: nil, params: ["on_sale": "true"]), | |
| 132 | + .init(id: "premiere-auto", title: "Première auto", | |
| 133 | + subtitle: "Des véhicules récents sous 15 000 $", | |
| 134 | + symbol: "car.2.fill", universeID: "auto-ka", | |
| 135 | + query: nil, params: ["price_max": "15000", "year_min": "2015"]), | |
| 136 | + .init(id: "quatre-et-demi", title: "Le classique 4½", | |
| 137 | + subtitle: "Les 4½ fraîchement affichés", | |
| 138 | + symbol: "key.fill", universeID: "lou-ka", | |
| 139 | + query: nil, params: ["unit_type": "4½"]), | |
| 140 | + ] | |
| 141 | +} | |
modified
KA/Core/Services.swift
+43 −0
@@ -90,6 +90,49 @@ enum UniverseService { | ||
| 90 | 90 | return raw.compactMap { $0.object.flatMap(map) } |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | + /// Total d'une requête (champ `total` de la réponse liste) — pour les alertes. | |
| 94 | + static func total(_ u: Universe, query: String? = nil, params: [String: String] = [:]) async -> Int? { | |
| 95 | + guard let url = listURL(u, query: query, limit: 1, params: params), | |
| 96 | + let root = try? await APIClient.shared.json(url, ttl: 60) else { return nil } | |
| 97 | + return root.object?.num("total", "count").map(Int.init) | |
| 98 | + } | |
| 99 | + | |
| 100 | + /// Items GÉOLOCALISÉS pour une région de carte. | |
| 101 | + /// lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié). | |
| 102 | + /// Autres univers : liste standard filtrée client sur la région. | |
| 103 | + static func mapItems(_ u: Universe, west: Double, south: Double, east: Double, north: Double, | |
| 104 | + limit: Int = 150) async -> [KAItem] { | |
| 105 | + let bboxCapable = ["lou-ka": "/api/listings.geojson", | |
| 106 | + "immo-ka": "/api/listings.geojson", | |
| 107 | + "job-ka": "/api/jobs.geojson"] | |
| 108 | + if let path = bboxCapable[u.id], let map = u.map { | |
| 109 | + var comps = URLComponents(string: "https://\(u.domain)\(path)")! | |
| 110 | + comps.queryItems = [ | |
| 111 | + .init(name: "bbox", value: "\(west),\(south),\(east),\(north)"), | |
| 112 | + .init(name: "limit", value: String(limit)), | |
| 113 | + ] | |
| 114 | + guard let url = comps.url, | |
| 115 | + let root = try? await APIClient.shared.json(url, ttl: 90), | |
| 116 | + let features = root.object?["features"]?.array else { return [] } | |
| 117 | + return features.compactMap { f -> KAItem? in | |
| 118 | + guard let fo = f.object, | |
| 119 | + var props = fo["properties"]?.object, | |
| 120 | + let coords = fo["geometry"]?.object?["coordinates"]?.array, | |
| 121 | + coords.count >= 2, let lon = coords[0].number, let lat = coords[1].number | |
| 122 | + else { return nil } | |
| 123 | + props["lat"] = .number(lat) | |
| 124 | + props["lng"] = .number(lon) | |
| 125 | + return map(props) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + // repli : liste + filtre client | |
| 129 | + let items = (try? await fetch(u, limit: limit)) ?? [] | |
| 130 | + return items.filter { it in | |
| 131 | + guard let la = it.latitude, let lo = it.longitude else { return false } | |
| 132 | + return la >= south && la <= north && lo >= west && lo <= east | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 93 | 136 | /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés) |
| 94 | 137 | static func liveTotal(_ u: Universe) async -> Int? { |
| 95 | 138 | let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats" |
modified
KA/Features/HomeView.swift
+116 −0
@@ -9,6 +9,9 @@ struct HomeView: View { | ||
| 9 | 9 | @State private var pulse: [(Universe, Int)] = [] |
| 10 | 10 | @State private var loading = true |
| 11 | 11 | @AppStorage("ka.favUniverses") private var favUniversesRaw = "" |
| 12 | + @EnvironmentObject private var favorites: FavoritesStore | |
| 13 | + @EnvironmentObject private var recents: RecentsStore | |
| 14 | + @State private var editorialItems: [String: [KAItem]] = [:] | |
| 12 | 15 | @Environment(\.colorScheme) private var scheme |
| 13 | 16 | |
| 14 | 17 | var body: some View { |
@@ -17,9 +20,11 @@ struct HomeView: View { | ||
| 17 | 20 | VStack(alignment: .leading, spacing: 22) { |
| 18 | 21 | header |
| 19 | 22 | pulseStrip |
| 23 | + resumeStrip | |
| 20 | 24 | ForEach(featured, id: \.universe.id) { section in |
| 21 | 25 | featuredSection(section.universe, section.items) |
| 22 | 26 | } |
| 27 | + editorialSection | |
| 23 | 28 | if loading && featured.isEmpty { |
| 24 | 29 | ProgressView("KA prépare votre accueil…") |
| 25 | 30 | .frame(maxWidth: .infinity).padding(40) |
@@ -137,6 +142,106 @@ struct HomeView: View { | ||
| 137 | 142 | } |
| 138 | 143 | } |
| 139 | 144 | |
| 145 | + /// Reprendre : derniers consultés + favoris récents + recherches sauvegardées avec alertes | |
| 146 | + @ViewBuilder | |
| 147 | + private var resumeStrip: some View { | |
| 148 | + let hasContent = !recents.viewed.isEmpty || !favorites.allItems.isEmpty || !recents.savedSearches.isEmpty | |
| 149 | + if hasContent { | |
| 150 | + VStack(alignment: .leading, spacing: 10) { | |
| 151 | + Text("Reprendre").font(.headline) | |
| 152 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 153 | + HStack(spacing: 10) { | |
| 154 | + ForEach(recents.savedSearches.prefix(3)) { s in | |
| 155 | + NavigationLink(value: s.universeID) { | |
| 156 | + HStack(spacing: 6) { | |
| 157 | + Image(systemName: "bell.badge").font(.caption) | |
| 158 | + Text(s.name).font(.caption.weight(.bold)).lineLimit(1) | |
| 159 | + if s.newCount > 0 { | |
| 160 | + Text("+\(s.newCount)") | |
| 161 | + .font(.system(size: 10, design: .monospaced).weight(.bold)) | |
| 162 | + .padding(.horizontal, 6).padding(.vertical, 2) | |
| 163 | + .background(.red, in: Capsule()) | |
| 164 | + .foregroundStyle(.white) | |
| 165 | + } | |
| 166 | + } | |
| 167 | + .padding(.horizontal, 12).padding(.vertical, 9) | |
| 168 | + .kaCard() | |
| 169 | + } | |
| 170 | + .buttonStyle(KAPressStyle()) | |
| 171 | + } | |
| 172 | + ForEach(recents.viewed.prefix(6)) { item in | |
| 173 | + NavigationLink(value: item) { | |
| 174 | + HStack(spacing: 8) { | |
| 175 | + if let img = item.imageURL { | |
| 176 | + KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray) | |
| 177 | + .frame(width: 34, height: 34) | |
| 178 | + .clipShape(RoundedRectangle(cornerRadius: 7)) | |
| 179 | + } else { | |
| 180 | + Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) | |
| 181 | + } | |
| 182 | + Text(item.title).font(.caption.weight(.semibold)).lineLimit(1) | |
| 183 | + } | |
| 184 | + .padding(.horizontal, 10).padding(.vertical, 7) | |
| 185 | + .kaCard(accent: Ecosystem.universe(item.universeID)?.accent) | |
| 186 | + } | |
| 187 | + .buttonStyle(KAPressStyle()) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + /// Collections éditoriales : de vraies requêtes, du vrai contenu | |
| 196 | + @ViewBuilder | |
| 197 | + private var editorialSection: some View { | |
| 198 | + ForEach(Editorial.collections) { c in | |
| 199 | + if let items = editorialItems[c.id], !items.isEmpty, | |
| 200 | + let u = Ecosystem.universe(c.universeID) { | |
| 201 | + VStack(alignment: .leading, spacing: 10) { | |
| 202 | + HStack(spacing: 8) { | |
| 203 | + Image(systemName: c.symbol).foregroundStyle(u.accent) | |
| 204 | + VStack(alignment: .leading, spacing: 0) { | |
| 205 | + Text(c.title).font(.headline) | |
| 206 | + Text(c.subtitle).font(.caption).foregroundStyle(.secondary) | |
| 207 | + } | |
| 208 | + Spacer() | |
| 209 | + NavigationLink(value: u.id) { | |
| 210 | + Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent) | |
| 211 | + } | |
| 212 | + } | |
| 213 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 214 | + HStack(spacing: 12) { | |
| 215 | + ForEach(items.prefix(8)) { item in | |
| 216 | + NavigationLink(value: item) { | |
| 217 | + VStack(alignment: .leading, spacing: 0) { | |
| 218 | + KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol) | |
| 219 | + .frame(width: 170, height: 96).clipped() | |
| 220 | + VStack(alignment: .leading, spacing: 2) { | |
| 221 | + Text(item.title).font(.caption.weight(.semibold)) | |
| 222 | + .lineLimit(2, reservesSpace: true) | |
| 223 | + if let p = item.priceLabel { | |
| 224 | + Text(p).font(.system(.caption2, design: .rounded).weight(.bold)) | |
| 225 | + .foregroundStyle(u.accent) | |
| 226 | + } | |
| 227 | + } | |
| 228 | + .padding(9) | |
| 229 | + } | |
| 230 | + .frame(width: 170, alignment: .topLeading) | |
| 231 | + .kaCard(accent: u.accent) | |
| 232 | + } | |
| 233 | + .buttonStyle(KAPressStyle()) | |
| 234 | + } | |
| 235 | + } | |
| 236 | + .scrollTargetLayout() | |
| 237 | + .padding(.vertical, 4) | |
| 238 | + } | |
| 239 | + .scrollTargetBehavior(.viewAligned) | |
| 240 | + } | |
| 241 | + } | |
| 242 | + } | |
| 243 | + } | |
| 244 | + | |
| 140 | 245 | private var disclaimer: some View { |
| 141 | 246 | Text(Ecosystem.disclaimer) |
| 142 | 247 | .font(.caption2).foregroundStyle(.tertiary) |
@@ -191,5 +296,16 @@ struct HomeView: View { | ||
| 191 | 296 | } |
| 192 | 297 | } |
| 193 | 298 | pulse = counts.sorted { $0.1 > $1.1 } |
| 299 | + // collections éditoriales (requêtes réelles) | |
| 300 | + await withTaskGroup(of: (String, [KAItem]).self) { group in | |
| 301 | + for c in Editorial.collections { | |
| 302 | + if let u = Ecosystem.universe(c.universeID) { | |
| 303 | + group.addTask { | |
| 304 | + (c.id, (try? await UniverseService.fetch(u, query: c.query, limit: 8, params: c.params)) ?? []) | |
| 305 | + } | |
| 306 | + } | |
| 307 | + } | |
| 308 | + for await (id, items) in group { editorialItems[id] = items } | |
| 309 | + } | |
| 194 | 310 | } |
| 195 | 311 | } |
modified
KA/Features/MapView.swift
+271 −54
@@ -1,72 +1,163 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | −// MapView.swift — la CARTE UNIFIÉE : les résultats de plusieurs univers autour | |
| 3 | −// de l'utilisateur (logements, propriétés, emplois, restos, sorties) sur une | |
| 4 | −// même carte, épingles teintées par univers, filtre par univers. | |
| 2 | +// MapView.swift — la CARTE, pièce maîtresse : marqueurs-pilules avec prix, | |
| 3 | +// CLUSTERING par grille (dégroupage au zoom), « Rechercher dans cette zone » | |
| 4 | +// (bbox RÉEL côté API pour lou-ka/immo-ka/job-ka, filtre client sinon), | |
| 5 | +// fiche compacte au tap → fiche complète en feuille (état de carte conservé), | |
| 6 | +// bascule liste/carte synchronisée, chips par univers aux couleurs officielles. | |
| 5 | 7 | import SwiftUI |
| 6 | 8 | import MapKit |
| 7 | 9 | import CoreLocation |
| 8 | 10 | |
| 11 | +// MARK: - Clustering par grille (pur, testé dans KATests) | |
| 12 | + | |
| 13 | +struct MapCluster: Identifiable, Hashable { | |
| 14 | + let id: String | |
| 15 | + let latitude: Double | |
| 16 | + let longitude: Double | |
| 17 | + let items: [KAItem] | |
| 18 | + var isSingle: Bool { items.count == 1 } | |
| 19 | + var item: KAItem { items[0] } | |
| 20 | +} | |
| 21 | + | |
| 22 | +enum ClusterEngine { | |
| 23 | + /// Regroupe les items en cellules de grille (~grid × grid cellules sur la | |
| 24 | + /// région) ; une cellule d'un seul élément reste un marqueur individuel. | |
| 25 | + static func clusterize(_ items: [KAItem], region: MKCoordinateRegion, grid: Double = 9) -> [MapCluster] { | |
| 26 | + let cellLat = max(region.span.latitudeDelta / grid, 0.0001) | |
| 27 | + let cellLon = max(region.span.longitudeDelta / grid, 0.0001) | |
| 28 | + var cells: [String: [KAItem]] = [:] | |
| 29 | + for it in items { | |
| 30 | + guard let la = it.latitude, let lo = it.longitude else { continue } | |
| 31 | + let key = "\(Int((la / cellLat).rounded(.down)))|\(Int((lo / cellLon).rounded(.down)))" | |
| 32 | + cells[key, default: []].append(it) | |
| 33 | + } | |
| 34 | + return cells.map { key, members in | |
| 35 | + let la = members.compactMap(\.latitude).reduce(0, +) / Double(members.count) | |
| 36 | + let lo = members.compactMap(\.longitude).reduce(0, +) / Double(members.count) | |
| 37 | + return MapCluster(id: key + ":\(members.count)", latitude: la, longitude: lo, items: members) | |
| 38 | + } | |
| 39 | + .sorted { $0.items.count > $1.items.count } | |
| 40 | + .prefix(150) // plafond de rendu — jamais de carte qui rame | |
| 41 | + .map { $0 } | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +// MARK: - La carte unifiée | |
| 46 | + | |
| 9 | 47 | struct UnifiedMapView: View { |
| 48 | + /// true quand la carte est un ONGLET (pas de bouton fermer) | |
| 49 | + var embedded: Bool = false | |
| 10 | 50 | @State private var camera: MapCameraPosition = .region( |
| 11 | − MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21), // Québec | |
| 12 | − span: .init(latitudeDelta: 0.35, longitudeDelta: 0.35)) | |
| 13 | − ) | |
| 51 | + MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21), | |
| 52 | + span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))) | |
| 53 | + @State private var visibleRegion = MKCoordinateRegion( | |
| 54 | + center: .init(latitude: 46.81, longitude: -71.21), | |
| 55 | + span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) | |
| 14 | 56 | @State private var items: [KAItem] = [] |
| 15 | − @State private var enabled: Set<String> = ["lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka"] | |
| 57 | + @State private var enabled: Set<String> = ["lou-ka", "immo-ka"] | |
| 16 | 58 | @State private var loading = false |
| 17 | − @State private var selection: KAItem? | |
| 18 | − @State private var visibleRegion: MKCoordinateRegion? | |
| 59 | + @State private var zoneDirty = false | |
| 60 | + @State private var selectedCluster: MapCluster? | |
| 61 | + @State private var sheetItem: KAItem? | |
| 62 | + @State private var showList = false | |
| 19 | 63 | @StateObject private var location = LocationOnce() |
| 20 | 64 | @Environment(\.colorScheme) private var scheme |
| 21 | 65 | @Environment(\.dismiss) private var dismiss |
| 22 | 66 | |
| 67 | + /// Univers cartographiables (coordonnées disponibles) | |
| 23 | 68 | private var mapUniverses: [Universe] { |
| 24 | − Ecosystem.all.filter { ["lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka"].contains($0.id) } | |
| 69 | + Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka"].contains($0.id) } | |
| 25 | 70 | } |
| 71 | + private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } } | |
| 72 | + private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) } | |
| 26 | 73 | |
| 27 | 74 | var body: some View { |
| 28 | 75 | NavigationStack { |
| 29 | − Map(position: $camera, selection: $selection) { | |
| 30 | − UserAnnotation() | |
| 31 | − ForEach(items.filter { enabled.contains($0.universeID) }) { item in | |
| 32 | − if let lat = item.latitude, let lon = item.longitude, | |
| 33 | − let u = Ecosystem.universe(item.universeID) { | |
| 34 | − Marker(item.title, systemImage: u.symbol, | |
| 35 | − coordinate: .init(latitude: lat, longitude: lon)) | |
| 36 | − .tint(u.accent) | |
| 37 | − .tag(item) | |
| 38 | − } | |
| 76 | + ZStack(alignment: .bottom) { | |
| 77 | + mapLayer | |
| 78 | + if showList { listOverlay } | |
| 79 | + VStack(spacing: 10) { | |
| 80 | + if let c = selectedCluster, c.isSingle { compactCard(c.item) } | |
| 81 | + bottomBar | |
| 39 | 82 | } |
| 83 | + .padding(.horizontal, 12) | |
| 84 | + .padding(.bottom, 8) | |
| 40 | 85 | } |
| 41 | − .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) | |
| 42 | − .onMapCameraChange(frequency: .onEnd) { ctx in visibleRegion = ctx.region } | |
| 43 | 86 | .safeAreaInset(edge: .top) { chips } |
| 44 | − .safeAreaInset(edge: .bottom) { bottomBar } | |
| 45 | − .sheet(item: $selection) { item in | |
| 46 | − NavigationStack { ItemDetailView(item: item) } | |
| 47 | − .presentationDetents([.medium, .large]) | |
| 48 | − } | |
| 49 | 87 | .navigationTitle("Carte") |
| 50 | 88 | .navigationBarTitleDisplayMode(.inline) |
| 51 | 89 | .toolbar { |
| 52 | − ToolbarItem(placement: .topBarLeading) { | |
| 53 | − Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } | |
| 54 | − .accessibilityLabel("Fermer la carte") | |
| 90 | + if !embedded { | |
| 91 | + ToolbarItem(placement: .topBarLeading) { | |
| 92 | + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } | |
| 93 | + .accessibilityLabel("Fermer la carte") | |
| 94 | + } | |
| 55 | 95 | } |
| 96 | + ToolbarItem(placement: .topBarTrailing) { | |
| 97 | + Button { | |
| 98 | + Haptics.tap(); withAnimation(.snappy) { showList.toggle() } | |
| 99 | + } label: { | |
| 100 | + Image(systemName: showList ? "map.fill" : "list.bullet") | |
| 101 | + } | |
| 102 | + .accessibilityLabel(showList ? "Voir la carte" : "Voir la liste des résultats") | |
| 103 | + } | |
| 104 | + } | |
| 105 | + .sheet(item: $sheetItem) { item in | |
| 106 | + NavigationStack { ItemDetailView(item: item) } | |
| 107 | + .presentationDetents([.medium, .large]) | |
| 108 | + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) | |
| 56 | 109 | } |
| 57 | 110 | .task { |
| 58 | 111 | location.request() |
| 59 | − await load() | |
| 112 | + await loadZone() | |
| 60 | 113 | } |
| 61 | 114 | .onChange(of: location.coordinate != nil) { |
| 62 | 115 | if let c = location.coordinate { |
| 63 | − camera = .region(.init(center: c, span: .init(latitudeDelta: 0.25, longitudeDelta: 0.25))) | |
| 64 | − Task { await load() } | |
| 116 | + withAnimation { camera = .region(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18))) } | |
| 117 | + Task { await loadZone() } | |
| 65 | 118 | } |
| 66 | 119 | } |
| 67 | 120 | } |
| 68 | 121 | } |
| 69 | 122 | |
| 123 | + // MARK: couches | |
| 124 | + | |
| 125 | + private var mapLayer: some View { | |
| 126 | + Map(position: $camera) { | |
| 127 | + UserAnnotation() | |
| 128 | + ForEach(clusters) { cluster in | |
| 129 | + Annotation("", coordinate: .init(latitude: cluster.latitude, longitude: cluster.longitude)) { | |
| 130 | + if cluster.isSingle { | |
| 131 | + PricePill(item: cluster.item, | |
| 132 | + selected: selectedCluster?.id == cluster.id) | |
| 133 | + .onTapGesture { | |
| 134 | + Haptics.tap() | |
| 135 | + withAnimation(.snappy) { selectedCluster = cluster } | |
| 136 | + } | |
| 137 | + } else { | |
| 138 | + ClusterBadge(cluster: cluster) | |
| 139 | + .onTapGesture { | |
| 140 | + Haptics.rigid() | |
| 141 | + // dégroupage : zoom sur le groupe | |
| 142 | + withAnimation(.easeInOut(duration: 0.4)) { | |
| 143 | + camera = .region(.init( | |
| 144 | + center: .init(latitude: cluster.latitude, longitude: cluster.longitude), | |
| 145 | + span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2, | |
| 146 | + longitudeDelta: visibleRegion.span.longitudeDelta / 3.2))) | |
| 147 | + } | |
| 148 | + } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + .annotationTitles(.hidden) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) | |
| 155 | + .onMapCameraChange(frequency: .onEnd) { ctx in | |
| 156 | + visibleRegion = ctx.region | |
| 157 | + zoneDirty = true | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 70 | 161 | private var chips: some View { |
| 71 | 162 | ScrollView(.horizontal, showsIndicators: false) { |
| 72 | 163 | HStack(spacing: 8) { |
@@ -75,6 +166,8 @@ struct UnifiedMapView: View { | ||
| 75 | 166 | Button { |
| 76 | 167 | Haptics.tap() |
| 77 | 168 | if on { enabled.remove(u.id) } else { enabled.insert(u.id) } |
| 169 | + selectedCluster = nil | |
| 170 | + Task { await loadZone() } | |
| 78 | 171 | } label: { |
| 79 | 172 | Label(u.wordmark, systemImage: u.symbol) |
| 80 | 173 | .font(.system(.caption, design: .monospaced).weight(.bold)) |
@@ -83,6 +176,7 @@ struct UnifiedMapView: View { | ||
| 83 | 176 | .foregroundStyle(on ? .white : .secondary) |
| 84 | 177 | .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1)) |
| 85 | 178 | } |
| 179 | + .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte") | |
| 86 | 180 | } |
| 87 | 181 | } |
| 88 | 182 | .padding(.horizontal, 14).padding(.vertical, 8) |
@@ -91,39 +185,163 @@ struct UnifiedMapView: View { | ||
| 91 | 185 | } |
| 92 | 186 | |
| 93 | 187 | private var bottomBar: some View { |
| 94 | − HStack { | |
| 95 | − Text(loading ? "Chargement autour d'ici…" | |
| 96 | − : "\(items.filter { enabled.contains($0.universeID) && $0.latitude != nil }.count) résultats sur la carte") | |
| 188 | + HStack(spacing: 10) { | |
| 189 | + Text(loading ? "Chargement…" : "\(visibleItems.count) résultats") | |
| 97 | 190 | .font(.system(.caption, design: .monospaced).weight(.bold)) |
| 191 | + .padding(.horizontal, 11).padding(.vertical, 9) | |
| 192 | + .background(.ultraThinMaterial, in: Capsule()) | |
| 98 | 193 | Spacer() |
| 99 | − Button { | |
| 100 | − Haptics.rigid() | |
| 101 | − Task { await load() } | |
| 102 | − } label: { | |
| 103 | − Label("Chercher ici", systemImage: "arrow.clockwise") | |
| 104 | − .font(.caption.weight(.bold)) | |
| 105 | − .padding(.horizontal, 13).padding(.vertical, 9) | |
| 106 | − .background(KATheme.inkLight, in: Capsule()) | |
| 107 | − .foregroundStyle(KATheme.lime) | |
| 194 | + if zoneDirty && !loading { | |
| 195 | + Button { | |
| 196 | + Haptics.rigid() | |
| 197 | + Task { await loadZone() } | |
| 198 | + } label: { | |
| 199 | + Label("Rechercher dans cette zone", systemImage: "arrow.clockwise") | |
| 200 | + .font(.caption.weight(.bold)) | |
| 201 | + .padding(.horizontal, 13).padding(.vertical, 10) | |
| 202 | + .background(KATheme.inkLight, in: Capsule()) | |
| 203 | + .foregroundStyle(KATheme.lime) | |
| 204 | + } | |
| 205 | + .transition(.scale.combined(with: .opacity)) | |
| 108 | 206 | } |
| 109 | 207 | } |
| 110 | − .padding(.horizontal, 14).padding(.vertical, 9) | |
| 111 | − .background(.ultraThinMaterial) | |
| 208 | + .animation(.snappy, value: zoneDirty) | |
| 112 | 209 | } |
| 113 | 210 | |
| 114 | − /// Charge les items géolocalisés des univers actifs (autour de la vue). | |
| 115 | − private func load() async { | |
| 211 | + private func compactCard(_ item: KAItem) -> some View { | |
| 212 | + Button { | |
| 213 | + Haptics.tap() | |
| 214 | + sheetItem = item // feuille : l'état de la carte reste intact derrière | |
| 215 | + } label: { | |
| 216 | + HStack(spacing: 10) { | |
| 217 | + if let img = item.imageURL { | |
| 218 | + KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray) | |
| 219 | + .frame(width: 58, height: 58) | |
| 220 | + .clipShape(RoundedRectangle(cornerRadius: 9)) | |
| 221 | + } | |
| 222 | + VStack(alignment: .leading, spacing: 3) { | |
| 223 | + Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1) | |
| 224 | + HStack(spacing: 6) { | |
| 225 | + if let p = item.priceLabel { | |
| 226 | + Text(p).font(.system(.caption, design: .rounded).weight(.bold)) | |
| 227 | + .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary) | |
| 228 | + } | |
| 229 | + if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) } | |
| 230 | + } | |
| 231 | + Text("Toucher pour la fiche complète").font(.system(size: 9, design: .monospaced)) | |
| 232 | + .foregroundStyle(.tertiary) | |
| 233 | + } | |
| 234 | + Spacer() | |
| 235 | + Button { | |
| 236 | + withAnimation(.snappy) { selectedCluster = nil } | |
| 237 | + } label: { | |
| 238 | + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) | |
| 239 | + } | |
| 240 | + .accessibilityLabel("Fermer l'aperçu") | |
| 241 | + } | |
| 242 | + .padding(11) | |
| 243 | + .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) | |
| 244 | + .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.primary.opacity(0.5), lineWidth: 1.4)) | |
| 245 | + .shadow(color: .black.opacity(0.18), radius: 8, y: 4) | |
| 246 | + } | |
| 247 | + .buttonStyle(KAPressStyle()) | |
| 248 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 249 | + } | |
| 250 | + | |
| 251 | + private var listOverlay: some View { | |
| 252 | + ScrollView { | |
| 253 | + LazyVStack(spacing: 10) { | |
| 254 | + ForEach(visibleItems.prefix(60)) { item in | |
| 255 | + Button { | |
| 256 | + Haptics.tap() | |
| 257 | + withAnimation(.snappy) { | |
| 258 | + showList = false | |
| 259 | + if let la = item.latitude, let lo = item.longitude { | |
| 260 | + camera = .region(.init(center: .init(latitude: la, longitude: lo), | |
| 261 | + span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02))) | |
| 262 | + } | |
| 263 | + selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0, | |
| 264 | + longitude: item.longitude ?? 0, items: [item]) | |
| 265 | + } | |
| 266 | + } label: { KAItemRow(item: item) } | |
| 267 | + .buttonStyle(KAPressStyle()) | |
| 268 | + } | |
| 269 | + } | |
| 270 | + .padding(12) | |
| 271 | + } | |
| 272 | + .background(KATheme.paper(scheme).opacity(0.98)) | |
| 273 | + } | |
| 274 | + | |
| 275 | + // MARK: données | |
| 276 | + | |
| 277 | + private func loadZone() async { | |
| 116 | 278 | loading = true |
| 279 | + zoneDirty = false | |
| 280 | + selectedCluster = nil | |
| 281 | + let r = visibleRegion | |
| 282 | + let west = r.center.longitude - r.span.longitudeDelta / 2 | |
| 283 | + let east = r.center.longitude + r.span.longitudeDelta / 2 | |
| 284 | + let south = r.center.latitude - r.span.latitudeDelta / 2 | |
| 285 | + let north = r.center.latitude + r.span.latitudeDelta / 2 | |
| 117 | 286 | var all: [KAItem] = [] |
| 118 | 287 | await withTaskGroup(of: [KAItem].self) { group in |
| 119 | − for u in mapUniverses { | |
| 120 | − group.addTask { (try? await UniverseService.fetch(u, limit: 80)) ?? [] } | |
| 288 | + for u in mapUniverses where enabled.contains(u.id) { | |
| 289 | + group.addTask { | |
| 290 | + await UniverseService.mapItems(u, west: west, south: south, east: east, north: north) | |
| 291 | + } | |
| 121 | 292 | } |
| 122 | 293 | for await batch in group { all.append(contentsOf: batch) } |
| 123 | 294 | } |
| 124 | − items = all.filter { $0.latitude != nil && $0.longitude != nil } | |
| 295 | + withAnimation(.easeOut(duration: 0.25)) { items = all } | |
| 125 | 296 | loading = false |
| 126 | − if !items.isEmpty { Haptics.tap() } | |
| 297 | + if !all.isEmpty { Haptics.tap() } | |
| 298 | + } | |
| 299 | +} | |
| 300 | + | |
| 301 | +// MARK: - Marqueurs | |
| 302 | + | |
| 303 | +/// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers. | |
| 304 | +struct PricePill: View { | |
| 305 | + let item: KAItem | |
| 306 | + var selected: Bool | |
| 307 | + private var universe: Universe? { Ecosystem.universe(item.universeID) } | |
| 308 | + | |
| 309 | + var body: some View { | |
| 310 | + HStack(spacing: 3) { | |
| 311 | + Image(systemName: universe?.symbol ?? "mappin") | |
| 312 | + .font(.system(size: 9, weight: .bold)) | |
| 313 | + if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") { | |
| 314 | + Text(p).font(.system(size: 11, weight: .bold, design: .rounded)) | |
| 315 | + } | |
| 316 | + } | |
| 317 | + .padding(.horizontal, 8).padding(.vertical, 5) | |
| 318 | + .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule()) | |
| 319 | + .foregroundStyle(selected ? KATheme.lime : .white) | |
| 320 | + .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4)) | |
| 321 | + .shadow(color: .black.opacity(0.3), radius: 2, y: 1) | |
| 322 | + .scaleEffect(selected ? 1.18 : 1) | |
| 323 | + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected) | |
| 324 | + .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")") | |
| 325 | + } | |
| 326 | +} | |
| 327 | + | |
| 328 | +/// Badge de groupe : compte + camembert des univers présents. | |
| 329 | +struct ClusterBadge: View { | |
| 330 | + let cluster: MapCluster | |
| 331 | + private var dominant: Color { | |
| 332 | + let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count) | |
| 333 | + let top = counts.max { $0.value < $1.value }?.key | |
| 334 | + return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray | |
| 335 | + } | |
| 336 | + var body: some View { | |
| 337 | + Text("\(cluster.items.count)") | |
| 338 | + .font(.system(size: 13, weight: .bold, design: .rounded)) | |
| 339 | + .frame(minWidth: 34, minHeight: 34) | |
| 340 | + .background(dominant, in: Circle()) | |
| 341 | + .foregroundStyle(.white) | |
| 342 | + .overlay(Circle().strokeBorder(.white, lineWidth: 2)) | |
| 343 | + .shadow(color: .black.opacity(0.3), radius: 2, y: 1) | |
| 344 | + .accessibilityLabel("Groupe de \(cluster.items.count) résultats — toucher pour zoomer") | |
| 127 | 345 | } |
| 128 | 346 | } |
| 129 | 347 | |
@@ -140,7 +358,6 @@ final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate | ||
| 140 | 358 | manager.requestLocation() |
| 141 | 359 | } |
| 142 | 360 | } |
| 143 | − | |
| 144 | 361 | func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { |
| 145 | 362 | if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways { |
| 146 | 363 | m.requestLocation() |
modified
KA/Features/ProfileView.swift
+105 −0
@@ -8,6 +8,8 @@ struct ProfileView: View { | ||
| 8 | 8 | @AppStorage("ka.appearance") private var appearance = "clair" |
| 9 | 9 | @Environment(\.colorScheme) private var scheme |
| 10 | 10 | @StateObject private var kaid = KAIDManager.shared |
| 11 | + @EnvironmentObject private var favorites: FavoritesStore | |
| 12 | + @EnvironmentObject private var recents: RecentsStore | |
| 11 | 13 | |
| 12 | 14 | private var favIDs: Set<String> { |
| 13 | 15 | Set(favUniversesRaw.split(separator: ",").map(String.init)) |
@@ -62,6 +64,21 @@ struct ProfileView: View { | ||
| 62 | 64 | } |
| 63 | 65 | } |
| 64 | 66 | |
| 67 | + Section("Mes contenus") { | |
| 68 | + NavigationLink { FavoritesView() } label: { | |
| 69 | + Label("Favoris & collections", systemImage: "heart.fill") | |
| 70 | + .badge(favorites.allItems.count) | |
| 71 | + } | |
| 72 | + NavigationLink { HistoryView() } label: { | |
| 73 | + Label("Historique consulté", systemImage: "clock.arrow.circlepath") | |
| 74 | + .badge(recents.viewed.count) | |
| 75 | + } | |
| 76 | + NavigationLink { SavedSearchesView() } label: { | |
| 77 | + Label("Recherches sauvegardées & alertes", systemImage: "bell.badge") | |
| 78 | + .badge(recents.alertCount > 0 ? "\(recents.alertCount) nouv." : "") | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 65 | 82 | Section { |
| 66 | 83 | ForEach(Ecosystem.all) { u in |
| 67 | 84 | Button { |
@@ -134,3 +151,91 @@ struct ProfileView: View { | ||
| 134 | 151 | } |
| 135 | 152 | } |
| 136 | 153 | } |
| 154 | + | |
| 155 | + | |
| 156 | +// MARK: - Historique consulté | |
| 157 | + | |
| 158 | +struct HistoryView: View { | |
| 159 | + @EnvironmentObject private var recents: RecentsStore | |
| 160 | + @Environment(\.colorScheme) private var scheme | |
| 161 | + var body: some View { | |
| 162 | + ScrollView { | |
| 163 | + LazyVStack(spacing: 10) { | |
| 164 | + if recents.viewed.isEmpty { | |
| 165 | + KAEmptyState(symbol: "clock", title: "Rien de consulté encore", | |
| 166 | + message: "Les fiches que vous ouvrez apparaîtront ici.") | |
| 167 | + } | |
| 168 | + ForEach(recents.viewed) { item in | |
| 169 | + NavigationLink(value: item) { KAItemRow(item: item) } | |
| 170 | + .buttonStyle(KAPressStyle()) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + .padding(14) | |
| 174 | + } | |
| 175 | + .background(KATheme.paper(scheme)) | |
| 176 | + .navigationTitle("Historique") | |
| 177 | + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 178 | + .toolbar { | |
| 179 | + if !recents.viewed.isEmpty { | |
| 180 | + ToolbarItem(placement: .topBarTrailing) { | |
| 181 | + Button("Effacer") { recents.clearHistory() } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +// MARK: - Recherches sauvegardées & alertes | |
| 189 | + | |
| 190 | +struct SavedSearchesView: View { | |
| 191 | + @EnvironmentObject private var recents: RecentsStore | |
| 192 | + @Environment(\.colorScheme) private var scheme | |
| 193 | + var body: some View { | |
| 194 | + List { | |
| 195 | + if recents.savedSearches.isEmpty { | |
| 196 | + KAEmptyState(symbol: "bell.slash", title: "Aucune recherche sauvegardée", | |
| 197 | + message: "Dans Recherche, touchez 🔔 pour suivre une recherche : KA recomptera les résultats et affichera les nouveautés.") | |
| 198 | + .listRowBackground(Color.clear) | |
| 199 | + } | |
| 200 | + ForEach($recents.savedSearches) { $s in | |
| 201 | + VStack(alignment: .leading, spacing: 6) { | |
| 202 | + HStack { | |
| 203 | + Text(s.name).font(.headline) | |
| 204 | + if s.newCount > 0 { | |
| 205 | + Text("+\(s.newCount) nouveautés") | |
| 206 | + .font(.system(size: 10, design: .monospaced).weight(.bold)) | |
| 207 | + .padding(.horizontal, 7).padding(.vertical, 3) | |
| 208 | + .background(.red, in: Capsule()).foregroundStyle(.white) | |
| 209 | + } | |
| 210 | + Spacer() | |
| 211 | + Toggle("", isOn: $s.alertsOn).labelsHidden() | |
| 212 | + .accessibilityLabel("Alerte pour \(s.name)") | |
| 213 | + } | |
| 214 | + HStack(spacing: 8) { | |
| 215 | + if let u = Ecosystem.universe(s.universeID) { KAChip(text: u.wordmark, accent: u.accent) } | |
| 216 | + Text("« \(s.query) »").font(.caption).foregroundStyle(.secondary) | |
| 217 | + Spacer() | |
| 218 | + if let total = s.lastTotal { | |
| 219 | + Text("\(total + s.newCount) résultats") | |
| 220 | + .font(.system(.caption2, design: .monospaced)) | |
| 221 | + .foregroundStyle(.tertiary) | |
| 222 | + } | |
| 223 | + } | |
| 224 | + if s.newCount > 0 { | |
| 225 | + Button("Marquer comme vues") { recents.markSeen(s.id) } | |
| 226 | + .font(.caption.weight(.semibold)) | |
| 227 | + } | |
| 228 | + } | |
| 229 | + .swipeActions { | |
| 230 | + Button(role: .destructive) { recents.remove(s.id) } label: { | |
| 231 | + Label("Supprimer", systemImage: "trash") | |
| 232 | + } | |
| 233 | + } | |
| 234 | + } | |
| 235 | + } | |
| 236 | + .navigationTitle("Recherches & alertes") | |
| 237 | + .refreshable { await recents.refreshAlerts() } | |
| 238 | + .scrollContentBackground(.hidden) | |
| 239 | + .background(KATheme.paper(scheme)) | |
| 240 | + } | |
| 241 | +} | |
modified
KA/Features/SearchView.swift
+50 −1
@@ -11,9 +11,30 @@ struct SearchView: View { | ||
| 11 | 11 | @State private var sections: [(universe: Universe, items: [KAItem])] = [] |
| 12 | 12 | @State private var searching = false |
| 13 | 13 | @State private var selected: Set<String> = [] // filtres univers (vide = tous) |
| 14 | + @State private var sort: SortMode = .pertinence | |
| 15 | + @State private var showSave = false | |
| 16 | + @State private var saveName = "" | |
| 14 | 17 | @AppStorage("ka.search.history") private var historyRaw = "" |
| 18 | + @EnvironmentObject private var recents: RecentsStore | |
| 15 | 19 | @Environment(\.colorScheme) private var scheme |
| 16 | 20 | |
| 21 | + enum SortMode: String, CaseIterable { | |
| 22 | + case pertinence = "Pertinence" | |
| 23 | + case prixAsc = "Prix ↑" | |
| 24 | + case prixDesc = "Prix ↓" | |
| 25 | + } | |
| 26 | + | |
| 27 | + private func sorted(_ items: [KAItem]) -> [KAItem] { | |
| 28 | + switch sort { | |
| 29 | + case .pertinence: return items | |
| 30 | + case .prixAsc: return items.sorted { price($0) < price($1) } | |
| 31 | + case .prixDesc: return items.sorted { price($0) > price($1) } | |
| 32 | + } | |
| 33 | + } | |
| 34 | + private func price(_ i: KAItem) -> Double { | |
| 35 | + Double(i.priceLabel?.filter { $0.isNumber } ?? "") ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0) | |
| 36 | + } | |
| 37 | + | |
| 17 | 38 | private var history: [String] { historyRaw.split(separator: "\n").map(String.init) } |
| 18 | 39 | private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } } |
| 19 | 40 | |
@@ -45,6 +66,34 @@ struct SearchView: View { | ||
| 45 | 66 | .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…") |
| 46 | 67 | .onSubmit(of: .search) { Task { await search() } } |
| 47 | 68 | .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } |
| 69 | + .toolbar { | |
| 70 | + if !submitted.isEmpty { | |
| 71 | + ToolbarItemGroup(placement: .topBarTrailing) { | |
| 72 | + Menu { | |
| 73 | + Picker("Tri", selection: $sort) { | |
| 74 | + ForEach(SortMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } | |
| 75 | + } | |
| 76 | + } label: { Image(systemName: "arrow.up.arrow.down") } | |
| 77 | + .accessibilityLabel("Trier les résultats") | |
| 78 | + Button { saveName = submitted; showSave = true } label: { | |
| 79 | + Image(systemName: "bell.badge") | |
| 80 | + } | |
| 81 | + .accessibilityLabel("Sauvegarder cette recherche et créer une alerte") | |
| 82 | + ShareLink(item: URL(string: "https://www.trouve-ka.com/search?q=\(submitted.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!, | |
| 83 | + subject: Text("Recherche KA : \(submitted)")) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + .alert("Sauvegarder la recherche", isPresented: $showSave) { | |
| 88 | + TextField("Nom", text: $saveName) | |
| 89 | + Button("Sauvegarder + alerte") { | |
| 90 | + let uid = selected.count == 1 ? selected.first! : (sections.max { $0.items.count < $1.items.count }?.universe.id ?? "lou-ka") | |
| 91 | + recents.saveSearch(name: saveName, universeID: uid, query: submitted, params: [:]) | |
| 92 | + } | |
| 93 | + Button("Annuler", role: .cancel) {} | |
| 94 | + } message: { | |
| 95 | + Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).") | |
| 96 | + } | |
| 48 | 97 | } |
| 49 | 98 | } |
| 50 | 99 | |
@@ -116,7 +165,7 @@ struct SearchView: View { | ||
| 116 | 165 | .font(.system(.caption, design: .monospaced).weight(.bold)) |
| 117 | 166 | .foregroundStyle(section.universe.accent) |
| 118 | 167 | } |
| 119 | − ForEach(section.items.prefix(5)) { item in | |
| 168 | + ForEach(sorted(section.items).prefix(5)) { item in | |
| 120 | 169 | NavigationLink(value: item) { KAItemRow(item: item) } |
| 121 | 170 | .buttonStyle(KAPressStyle()) |
| 122 | 171 | } |
modified
KA/Features/UniversesView.swift
+126 −13
@@ -4,6 +4,43 @@ | ||
| 4 | 4 | // fiche universelle (faits, favori, partage, lien web). |
| 5 | 5 | import SwiftUI |
| 6 | 6 | import SafariServices |
| 7 | +import MapKit | |
| 8 | + | |
| 9 | +extension URL: @retroactive Identifiable { public var id: String { absoluteString } } | |
| 10 | + | |
| 11 | +/// Visionneuse plein écran avec zoom (pincement + double-tap) | |
| 12 | +struct ZoomableImageView: View { | |
| 13 | + let url: URL | |
| 14 | + let accent: Color | |
| 15 | + @Environment(\.dismiss) private var dismiss | |
| 16 | + @State private var scale: CGFloat = 1 | |
| 17 | + @State private var lastScale: CGFloat = 1 | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + ZStack(alignment: .topTrailing) { | |
| 21 | + Color.black.ignoresSafeArea() | |
| 22 | + KAImage(url: url, accent: accent) | |
| 23 | + .aspectRatio(contentMode: .fit) | |
| 24 | + .scaleEffect(scale) | |
| 25 | + .gesture( | |
| 26 | + MagnifyGesture() | |
| 27 | + .onChanged { v in scale = min(max(lastScale * v.magnification, 1), 5) } | |
| 28 | + .onEnded { _ in lastScale = scale } | |
| 29 | + ) | |
| 30 | + .onTapGesture(count: 2) { | |
| 31 | + withAnimation(.snappy) { scale = scale > 1.5 ? 1 : 2.5; lastScale = scale } | |
| 32 | + } | |
| 33 | + .accessibilityLabel("Photo en plein écran, pincer pour zoomer") | |
| 34 | + Button { dismiss() } label: { | |
| 35 | + Image(systemName: "xmark.circle.fill") | |
| 36 | + .font(.title2).foregroundStyle(.white.opacity(0.85)) | |
| 37 | + .padding(16) | |
| 38 | + } | |
| 39 | + .accessibilityLabel("Fermer la photo") | |
| 40 | + } | |
| 41 | + .statusBarHidden() | |
| 42 | + } | |
| 43 | +} | |
| 7 | 44 | |
| 8 | 45 | // MARK: - Grille des univers |
| 9 | 46 | |
@@ -220,20 +257,38 @@ struct ItemDetailView: View { | ||
| 220 | 257 | @Environment(\.colorScheme) private var scheme |
| 221 | 258 | @State private var showCollections = false |
| 222 | 259 | @State private var menu: [RestoMenuSection] = [] |
| 260 | + @State private var similar: [KAItem] = [] | |
| 261 | + @State private var fullScreenImage: URL? | |
| 262 | + @EnvironmentObject private var recents: RecentsStore | |
| 223 | 263 | |
| 224 | 264 | private var universe: Universe? { Ecosystem.universe(item.universeID) } |
| 225 | 265 | |
| 226 | 266 | var body: some View { |
| 227 | 267 | ScrollView { |
| 228 | 268 | VStack(alignment: .leading, spacing: 16) { |
| 229 | − if let img = item.imageURL { | |
| 230 | − KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | |
| 231 | − .frame(maxWidth: .infinity) | |
| 232 | − .frame(height: 230) | |
| 269 | + if !item.imageURLs.isEmpty { | |
| 270 | + TabView { | |
| 271 | + ForEach(item.imageURLs, id: \.self) { img in | |
| 272 | + KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | |
| 273 | + .onTapGesture { Haptics.tap(); fullScreenImage = img } | |
| 274 | + .accessibilityLabel("Photo de \(item.title) — toucher pour agrandir") | |
| 275 | + .accessibilityAddTraits(.isButton) | |
| 276 | + } | |
| 277 | + } | |
| 278 | + .tabViewStyle(.page(indexDisplayMode: item.imageURLs.count > 1 ? .automatic : .never)) | |
| 279 | + .frame(height: 240) | |
| 233 | 280 | .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| 234 | 281 | .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) |
| 235 | 282 | .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2)) |
| 236 | − .accessibilityLabel("Photo : \(item.title)") | |
| 283 | + .overlay(alignment: .topTrailing) { | |
| 284 | + if item.imageURLs.count > 1 { | |
| 285 | + Text("\(item.imageURLs.count) photos") | |
| 286 | + .font(.system(size: 10, design: .monospaced).weight(.bold)) | |
| 287 | + .padding(.horizontal, 8).padding(.vertical, 4) | |
| 288 | + .background(.ultraThinMaterial, in: Capsule()) | |
| 289 | + .padding(8) | |
| 290 | + } | |
| 291 | + } | |
| 237 | 292 | } |
| 238 | 293 | if let u = universe { |
| 239 | 294 | HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() } |
@@ -331,17 +386,67 @@ struct ItemDetailView: View { | ||
| 331 | 386 | } |
| 332 | 387 | } |
| 333 | 388 | |
| 334 | − if let url = item.url { | |
| 335 | − Link(destination: url) { | |
| 336 | − Label("Voir à la source", systemImage: "arrow.up.right.square") | |
| 337 | − .font(.headline) | |
| 338 | − .frame(maxWidth: .infinity).padding(.vertical, 14) | |
| 339 | − .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 340 | − .foregroundStyle(.white) | |
| 389 | + HStack(spacing: 10) { | |
| 390 | + if let url = item.url { | |
| 391 | + Link(destination: url) { | |
| 392 | + Label("Voir à la source", systemImage: "arrow.up.right.square") | |
| 393 | + .font(.headline) | |
| 394 | + .frame(maxWidth: .infinity).padding(.vertical, 14) | |
| 395 | + .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 396 | + .foregroundStyle(.white) | |
| 397 | + } | |
| 341 | 398 | } |
| 342 | − Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.") | |
| 399 | + if let la = item.latitude, let lo = item.longitude { | |
| 400 | + Button { | |
| 401 | + Haptics.tap() | |
| 402 | + let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo))) | |
| 403 | + place.name = item.title | |
| 404 | + place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault]) | |
| 405 | + } label: { | |
| 406 | + Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill") | |
| 407 | + .font(.headline) | |
| 408 | + .padding(.horizontal, 16).padding(.vertical, 14) | |
| 409 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 410 | + .foregroundStyle(KATheme.lime) | |
| 411 | + } | |
| 412 | + .accessibilityLabel("Itinéraire vers \(item.title)") | |
| 413 | + } | |
| 414 | + } | |
| 415 | + if let src = item.url?.host() { | |
| 416 | + Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.") | |
| 343 | 417 | .font(.caption2).foregroundStyle(.tertiary) |
| 344 | 418 | } |
| 419 | + | |
| 420 | + if !similar.isEmpty { | |
| 421 | + VStack(alignment: .leading, spacing: 10) { | |
| 422 | + Text("Semblables\(item.city.map { " à \($0)" } ?? "")").font(.headline) | |
| 423 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 424 | + HStack(spacing: 10) { | |
| 425 | + ForEach(similar) { s in | |
| 426 | + NavigationLink(value: s) { | |
| 427 | + VStack(alignment: .leading, spacing: 0) { | |
| 428 | + KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | |
| 429 | + .frame(width: 150, height: 84).clipped() | |
| 430 | + VStack(alignment: .leading, spacing: 2) { | |
| 431 | + Text(s.title).font(.caption.weight(.semibold)) | |
| 432 | + .lineLimit(2, reservesSpace: true) | |
| 433 | + if let p = s.priceLabel { | |
| 434 | + Text(p).font(.system(.caption2, design: .rounded).weight(.bold)) | |
| 435 | + .foregroundStyle(universe?.accent ?? .primary) | |
| 436 | + } | |
| 437 | + } | |
| 438 | + .padding(8) | |
| 439 | + } | |
| 440 | + .frame(width: 150, alignment: .topLeading) | |
| 441 | + .kaCard(accent: universe?.accent) | |
| 442 | + } | |
| 443 | + .buttonStyle(KAPressStyle()) | |
| 444 | + } | |
| 445 | + } | |
| 446 | + .padding(.vertical, 4) | |
| 447 | + } | |
| 448 | + } | |
| 449 | + } | |
| 345 | 450 | } |
| 346 | 451 | .padding(16) |
| 347 | 452 | } |
@@ -364,10 +469,18 @@ struct ItemDetailView: View { | ||
| 364 | 469 | } |
| 365 | 470 | } |
| 366 | 471 | .task { |
| 472 | + recents.record(item) | |
| 367 | 473 | if item.universeID == "resto-ka" { |
| 368 | 474 | let uid = String(item.id.dropFirst("resto-ka:".count)) |
| 369 | 475 | menu = await RestoMenuLoader.load(uid: uid) |
| 370 | 476 | } |
| 477 | + if let u = universe, u.map != nil, similar.isEmpty { | |
| 478 | + let batch = (try? await UniverseService.fetch(u, city: item.city, limit: 10)) ?? [] | |
| 479 | + similar = batch.filter { $0.id != item.id }.prefix(6).map { $0 } | |
| 480 | + } | |
| 481 | + } | |
| 482 | + .fullScreenCover(item: $fullScreenImage) { img in | |
| 483 | + ZoomableImageView(url: img, accent: universe?.accent ?? .gray) | |
| 371 | 484 | } |
| 372 | 485 | .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) { |
| 373 | 486 | ForEach(favorites.collections) { c in |
modified
KATests/KATests.swift
+63 −0
@@ -90,3 +90,66 @@ final class AdapterTests: XCTestCase { | ||
| 90 | 90 | XCTAssertFalse(store.isFavorite(item)) |
| 91 | 91 | } |
| 92 | 92 | } |
| 93 | + | |
| 94 | +// MARK: - Refonte : clustering carte + historique/recherches sauvegardées | |
| 95 | + | |
| 96 | +import MapKit | |
| 97 | + | |
| 98 | +final class RefonteTests: XCTestCase { | |
| 99 | + private func item(_ id: String, lat: Double, lon: Double) -> KAItem { | |
| 100 | + KAItem(id: id, universeID: "lou-ka", title: id, subtitle: nil, priceLabel: nil, | |
| 101 | + city: nil, url: nil, imageURL: nil, latitude: lat, longitude: lon, | |
| 102 | + detail: nil, facts: []) | |
| 103 | + } | |
| 104 | + | |
| 105 | + func testClusteringGroupsNearbyAndKeepsSingles() { | |
| 106 | + let region = MKCoordinateRegion(center: .init(latitude: 46.8, longitude: -71.2), | |
| 107 | + span: .init(latitudeDelta: 0.5, longitudeDelta: 0.5)) | |
| 108 | + // 3 points quasi identiques + 1 isolé | |
| 109 | + let items = [ | |
| 110 | + item("a", lat: 46.800, lon: -71.200), | |
| 111 | + item("b", lat: 46.801, lon: -71.201), | |
| 112 | + item("c", lat: 46.8005, lon: -71.2005), | |
| 113 | + item("z", lat: 46.95, lon: -71.05), | |
| 114 | + ] | |
| 115 | + let clusters = ClusterEngine.clusterize(items, region: region) | |
| 116 | + XCTAssertEqual(clusters.count, 2) | |
| 117 | + XCTAssertEqual(clusters.first?.items.count, 3, "les voisins doivent se regrouper") | |
| 118 | + XCTAssertTrue(clusters.contains { $0.isSingle && $0.item.id == "z" }) | |
| 119 | + } | |
| 120 | + | |
| 121 | + func testClusteringCapsRenderCount() { | |
| 122 | + let region = MKCoordinateRegion(center: .init(latitude: 46, longitude: -71), | |
| 123 | + span: .init(latitudeDelta: 10, longitudeDelta: 10)) | |
| 124 | + let many = (0..<2000).map { i in | |
| 125 | + item("i\(i)", lat: 41 + Double(i % 500) * 0.02, lon: -76 + Double(i / 500) * 0.02) | |
| 126 | + } | |
| 127 | + let clusters = ClusterEngine.clusterize(many, region: region) | |
| 128 | + XCTAssertLessThanOrEqual(clusters.count, 150, "plafond de rendu pour la fluidité") | |
| 129 | + XCTAssertEqual(clusters.flatMap(\.items).count <= 2000, true) | |
| 130 | + } | |
| 131 | + | |
| 132 | + @MainActor | |
| 133 | + func testRecentsHistoryDedupAndCap() { | |
| 134 | + let store = RecentsStore() | |
| 135 | + store.clearHistory() | |
| 136 | + for i in 0..<40 { store.record(item("h\(i % 35)", lat: 0, lon: 0)) } | |
| 137 | + XCTAssertLessThanOrEqual(store.viewed.count, 30, "historique plafonné") | |
| 138 | + store.record(item("h1", lat: 0, lon: 0)) | |
| 139 | + XCTAssertEqual(store.viewed.first?.id, "h1", "re-consulter remonte en tête") | |
| 140 | + XCTAssertEqual(store.viewed.filter { $0.id == "h1" }.count, 1, "pas de doublon") | |
| 141 | + store.clearHistory() | |
| 142 | + XCTAssertTrue(store.viewed.isEmpty) | |
| 143 | + } | |
| 144 | + | |
| 145 | + @MainActor | |
| 146 | + func testSavedSearchMarkSeen() { | |
| 147 | + let store = RecentsStore() | |
| 148 | + store.savedSearches = [SavedSearch(name: "t", universeID: "lou-ka", query: "4½", | |
| 149 | + alertsOn: true, lastTotal: 100, newCount: 12)] | |
| 150 | + XCTAssertEqual(store.alertCount, 12) | |
| 151 | + store.markSeen(store.savedSearches[0].id) | |
| 152 | + XCTAssertEqual(store.savedSearches[0].newCount, 0) | |
| 153 | + XCTAssertEqual(store.savedSearches[0].lastTotal, 112, "le total vu absorbe les nouveautés") | |
| 154 | + } | |
| 155 | +} | |
added
docs/AUDIT-REFONTE.md
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +# Audit — refonte majeure de KA iOS (2026-08-18) | |
| 2 | + | |
| 3 | +## 1. État actuel du dépôt (v1.0.0 build 5) | |
| 4 | + | |
| 5 | +Architecture saine et déjà modulaire : `Core/` (Ecosystem config-driven 12 | |
| 6 | +univers + mappings JSON→KAItem, APIClient cache disque, UniverseService, | |
| 7 | +AgentService SSE, FavoritesStore, KAID natif, FilterCatalog), `Design/` | |
| 8 | +(éditorial sharp, clair par défaut), `Features/` (Accueil vivant, Recherche | |
| 9 | +universelle fan-out, grille Univers + explorateur générique + fiche, Carte | |
| 10 | +v1, Favoris/collections, Profil, KA Agent, Vrai-Prix natif, menus resto), | |
| 11 | +Widget « Le pouls », 8 tests d'adaptateurs. **À conserver tel quel** : le | |
| 12 | +principe config-driven, le thème, KA Agent, KA ID, Vrai-Prix natif, favoris. | |
| 13 | + | |
| 14 | +## 2. Écosystème (13 sites, harmonisés ka-ui le 2026-08-17) | |
| 15 | + | |
| 16 | +Identités et API connues et vérifiées (accents officiels d'ecosystem.json ; | |
| 17 | +chaque site : /api/<liste> + /api/stats + fiches). Découvertes clés pour la | |
| 18 | +refonte : **bbox géographique réel** sur lou-ka/immo-ka/job-ka | |
| 19 | +(`/api/*.geojson?bbox=w,s,e,n` — testé, filtre effectif, propriétés riches | |
| 20 | +avec price_label/image) ; `images[]` multiples (Auto·Ka 5/fiche) ; menus | |
| 21 | +resto (/api/restaurants/{uid}) ; estimation Vrai-Prix (/api/search + | |
| 22 | +/api/estimate?id=). | |
| 23 | + | |
| 24 | +## 3. Écarts sites ↔ app / faiblesses actuelles | |
| 25 | + | |
| 26 | +| Domaine | Constat | | |
| 27 | +|---|---| | |
| 28 | +| Carte | v1 rudimentaire : marqueurs standards, pas de clustering, pas de recherche par zone, pas de fiche compacte, pas de synchro liste↔carte, données limit=80 sans bbox | | |
| 29 | +| Fiches | 1 seule image (les sites en ont jusqu'à 5+), pas de galerie plein écran/zoom, pas d'itinéraire, pas de similaires | | |
| 30 | +| Recherche | pas d'autocomplétion/suggestions live, pas de tri, pas de recherches sauvegardées ni d'alertes, filtres actifs peu visibles, pas de partage | | |
| 31 | +| Accueil | pas de nouveautés/populaires, pas de collections éditoriales, pas d'« à proximité », pas de reprise d'historique | | |
| 32 | +| Historique | inexistant (ni éléments consultés, ni centre d'alertes) | | |
| 33 | +| Navigation | Carte cachée derrière Univers ; Favoris ok mais pas d'accès Historique/Alertes | | |
| 34 | +| Design | bon socle ; manquent tokens d'espacement formalisés, PriceBadge/SourceTag unifiés, iPad hors périmètre (choix : iPhone prioritaire) | | |
| 35 | + | |
| 36 | +## 4. Nouvelle architecture / plan | |
| 37 | + | |
| 38 | +- **Navigation** : 5 onglets — Accueil · Recherche · Explorer (univers) · | |
| 39 | + **Carte** (promue, priorité majeure) · Profil. Favoris/Historique/Recherches | |
| 40 | + sauvegardées & alertes : sections de l'Accueil + hub dans Profil. | |
| 41 | +- **Core+** : `KAItem.imageURLs[]` (galeries) ; `UniverseService.mapItems` | |
| 42 | + (bbox geojson pour lou/immo/job, repli liste+filtre client pour resto/ | |
| 43 | + sorti) ; `RecentsStore` (historique consulté, recherches sauvegardées avec | |
| 44 | + compteur d'alertes = delta honnête du total API à l'ouverture — pas de | |
| 45 | + fausses notifications) ; collections éditoriales = requêtes réelles. | |
| 46 | +- **Carte majeure** : marqueurs-pilules avec prix, **clustering grille** | |
| 47 | + maison (SwiftUI Map n'en a pas), dégroupage au zoom, chips par univers, | |
| 48 | + « Rechercher dans cette zone », fiche compacte → fiche complète en sheet | |
| 49 | + (état de carte conservé), mode liste/carte synchronisé, plafonds de | |
| 50 | + performance (rendu ≤ ~150 marqueurs après clustering). | |
| 51 | +- **Fiches** : galerie paginée + plein écran zoomable, itinéraire (Plans), | |
| 52 | + similaires (même univers/ville), provenance datée. | |
| 53 | +- **Recherche** : suggestions débouncées (locales + trouve-ka), tri | |
| 54 | + (pertinence/prix), chips de filtres actifs, sauvegarde+alerte+partage. | |
| 55 | +- **QA** : tests clustering/stores en plus des adaptateurs, previews, | |
| 56 | + validation simulateur clair/sombre + petit/grand iPhone. | |
| 57 | + | |
| 58 | +Ordre d'exécution : Core+ → Carte → Recherche → Fiches → Accueil/Explorer/ | |
| 59 | +Profil → tests/validation/TestFlight. Aucune WebView, aucune donnée fictive ; | |
| 60 | +tout ce qui n'a pas d'API (avis, horaires) est simplement absent. | |
| 61 | + | |
| 62 | +--- | |
| 63 | + | |
| 64 | +## 5. Livraison de la refonte (build 6, 2026-08-18) | |
| 65 | + | |
| 66 | +**Écrans reconstruits** : Carte (marqueurs-pilules prix, clustering grille + dégroupage au zoom, « Rechercher dans cette zone » bbox réel, fiche compacte → feuille avec carte intacte, bascule liste/carte, plafond 150 rendus) ; Fiches (galerie paginée multi-photos + plein écran zoomable, itinéraire Plans, semblables même ville, provenance) ; Recherche (tri pertinence/prix, sauvegarde + alerte, partage, filtres univers) ; Accueil (Reprendre : consultés + recherches sauvegardées avec +N, collections éditoriales réelles ×4, cartes photos) ; Profil (hub Favoris / Historique / Recherches & alertes avec deltas honnêtes et badge d'onglet). | |
| 67 | +**Ajouts d'architecture** : KAItem.imageURLs, UniverseService.mapItems (geojson bbox) + total(), RecentsStore, ClusterEngine pur testé, Editorial. **Navigation** : Accueil · Recherche · Explorer · Carte · Profil. | |
| 68 | +**Tests** : 12 (8 adaptateurs + clustering ×2, historique, alertes) — tous verts. **Validation visuelle** : iPhone 17e + 17 Pro Max, clair + sombre (docs/screenshots/refonte-*). | |
| 69 | +**Hors périmètre assumé** : iPad (choix iPhone prioritaire), avis/horaires (aucune API — rien de simulé). | |
added
docs/screenshots/refonte-iPhone-17-Pro-Max-clair.png
+0 −0
Binary file not shown.
added
docs/screenshots/refonte-iPhone-17-Pro-Max-sombre.png
+0 −0
Binary file not shown.
added
docs/screenshots/refonte-iPhone-17e-clair.png
+0 −0
Binary file not shown.
added
docs/screenshots/refonte-iPhone-17e-sombre.png
+0 −0
Binary file not shown.
modified
project.yml
+1 −1
@@ -10,7 +10,7 @@ settings: | ||
| 10 | 10 | base: |
| 11 | 11 | SWIFT_VERSION: "5.0" |
| 12 | 12 | MARKETING_VERSION: "1.0.0" |
| 13 | − CURRENT_PROJECT_VERSION: "5" | |
| 13 | + CURRENT_PROJECT_VERSION: "6" | |
| 14 | 14 | DEVELOPMENT_TEAM: "3YM54G49SN" |
| 15 | 15 | CODE_SIGN_STYLE: Automatic |
| 16 | 16 | GENERATE_INFOPLIST_FILE: true |
| 17 | 17 | |