SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 8a6ea55

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