SPB Git

spb/ka-ios

Public

v3.0.0 (9) — mode DÉCOUVRIR : radar piéton géolocalisé dans Trajet — en marchant, les logements Lou-Ka et propriétés Immo-Ka proches POPPENT (carte photo+prix+pièces/secteur+distance et flèche de direction LIVE à chaque fix GPS, haptique, file « suivant », une annonce = un pop par session) ; caméra 3D qui suit le cap GPS (NavCamera altitude/pitch paramétrables), pilules de prix live sur la carte (fix : synchro POI avant le retour anticipé navCamera), pulsations radar au centre, HUD encre/lime (bascules Lou-Ka/Immo-Ka + rayon 100/250/500 m), bouton d'entrée « Découvrir », dock masqué en immersion ; geojson enrichi (image, pièces, pi², secteur), itinéraire À PIED depuis la carte pop-up ; debug ka.debug.discover ; validé simulateur (marche simulée multi-fixes) + 12 tests verts

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

8 changed files +568 −25

modified KA/App/KAApp.swift +4 −2
@@ -63,14 +63,16 @@ struct RootView: View {
63 } 63 }
64 .tint(KATheme.green) 64 .tint(KATheme.green)
65 .safeAreaInset(edge: .bottom) { 65 .safeAreaInset(edge: .bottom) {
66− // le dock s'efface en mode conduite (Trajet plein écran) 66+ // le dock s'efface dans les modes immersifs de Trajet
67− if !trajet.navigating { 67+ // (conduite et Découvrir plein écran)
68+ if !trajet.navigating && !trajet.discovering {
68 KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true } 69 KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true }
69 .padding(.bottom, 4) 70 .padding(.bottom, 4)
70 .transition(.move(edge: .bottom).combined(with: .opacity)) 71 .transition(.move(edge: .bottom).combined(with: .opacity))
71 } 72 }
72 } 73 }
73 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating) 74 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating)
75+ .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.discovering)
74 .onChange(of: tab) { _, t in loaded.insert(t) } 76 .onChange(of: tab) { _, t in loaded.insert(t) }
75 .task { await recents.refreshAlerts() } 77 .task { await recents.refreshAlerts() }
76 .sheet(isPresented: $showAgent) { 78 .sheet(isPresented: $showAgent) {
added KA/Features/Trajet/DiscoverView.swift +346 −0
@@ -0,0 +1,346 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// DiscoverView.swift — le mode DÉCOUVRIR : un radar piéton géolocalisé.
3+// On marche, la caméra 3D suit, et dès qu'un logement à louer (Lou-Ka) ou
4+// une propriété à vendre (Immo-Ka) entre dans le rayon, sa carte POP avec
5+// photo, prix, distance et direction en direct. HUD encre/lime signature,
6+// pulsations radar au centre, file « suivant » quand plusieurs trouvailles.
7+import SwiftUI
8+import CoreLocation
9+
10+// MARK: - HUD du haut (remplace la barre de recherche en mode Découvrir)
11+
12+struct DiscoverHUD: View {
13+ @EnvironmentObject var model: TrajetModel
14+ @Environment(\.colorScheme) private var scheme
15+
16+ var body: some View {
17+ VStack(spacing: 10) {
18+ HStack(spacing: 10) {
19+ RadarIcon()
20+ VStack(alignment: .leading, spacing: 2) {
21+ Text("MODE DÉCOUVERTE")
22+ .font(KAFont.mono(10))
23+ .foregroundStyle(KATheme.lime)
24+ Text(statusLine)
25+ .font(.system(.caption, design: .rounded, weight: .semibold))
26+ .foregroundStyle(.white)
27+ .contentTransition(.numericText())
28+ }
29+ Spacer()
30+ Button {
31+ Haptics.tap()
32+ model.stopDiscover()
33+ } label: {
34+ Image(systemName: "xmark")
35+ .font(.system(size: 14, weight: .bold))
36+ .foregroundStyle(KATheme.inkLight)
37+ .frame(width: 32, height: 32)
38+ .background(KATheme.lime, in: Circle())
39+ }
40+ .accessibilityLabel("Quitter le mode Découvrir")
41+ }
42+
43+ HStack(spacing: 8) {
44+ kindChip(.louka)
45+ kindChip(.immoka)
46+ Spacer()
47+ radiusMenu
48+ }
49+ }
50+ .padding(14)
51+ .background(KATheme.inkLight.opacity(0.94), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
52+ .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
53+ .strokeBorder(KATheme.lime.opacity(0.45), lineWidth: 1.5))
54+ .shadow(color: .black.opacity(0.3), radius: 12, y: 5)
55+ .padding(.horizontal, 12)
56+ .padding(.top, 6)
57+ .transition(.move(edge: .top).combined(with: .opacity))
58+ }
59+
60+ private var statusLine: String {
61+ let n = model.discoverPOIs.count
62+ let q = model.discoverQueue.count
63+ var s = n == 0 ? "On scanne le quartier…"
64+ : "\(n) annonce\(n > 1 ? "s" : "") autour de vous"
65+ if q > 0 { s += " · \(q) en attente" }
66+ return s
67+ }
68+
69+ private func kindChip(_ k: POIKind) -> some View {
70+ let on = model.discoverKinds.contains(k)
71+ return Button {
72+ Haptics.tap()
73+ model.toggleDiscoverKind(k)
74+ } label: {
75+ Label(k.label, systemImage: k.icon)
76+ .font(.system(.caption2, design: .rounded, weight: .bold))
77+ .padding(.horizontal, 10)
78+ .padding(.vertical, 6)
79+ .background(on ? KATheme.lime : .white.opacity(0.12), in: Capsule())
80+ .foregroundStyle(on ? KATheme.inkLight : .white.opacity(0.8))
81+ }
82+ .accessibilityLabel("\(on ? "Désactiver" : "Activer") la couche \(k.label)")
83+ }
84+
85+ private var radiusMenu: some View {
86+ Menu {
87+ ForEach([100.0, 250.0, 500.0], id: \.self) { r in
88+ Button {
89+ model.setDiscoverRadius(r)
90+ } label: {
91+ Label("\(Int(r)) m", systemImage: model.discoverRadius == r ? "checkmark" : "circle.dashed")
92+ }
93+ }
94+ } label: {
95+ Label("\(Int(model.discoverRadius)) m", systemImage: "dot.radiowaves.left.and.right")
96+ .font(KAFont.mono(10))
97+ .padding(.horizontal, 10)
98+ .padding(.vertical, 6)
99+ .background(.white.opacity(0.12), in: Capsule())
100+ .foregroundStyle(KATheme.lime)
101+ }
102+ .accessibilityLabel("Rayon de détection : \(Int(model.discoverRadius)) mètres")
103+ }
104+}
105+
106+/// Petite icône radar qui pulse dans le HUD.
107+struct RadarIcon: View {
108+ @State private var on = false
109+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
110+ var body: some View {
111+ ZStack {
112+ Circle().stroke(KATheme.lime.opacity(0.5), lineWidth: 1.5)
113+ .scaleEffect(on ? 1.5 : 0.7)
114+ .opacity(on ? 0 : 0.9)
115+ Image(systemName: "sensor.tag.radiowaves.forward.fill")
116+ .font(.system(size: 17, weight: .bold))
117+ .foregroundStyle(KATheme.lime)
118+ }
119+ .frame(width: 34, height: 34)
120+ .onAppear {
121+ guard !reduceMotion else { return }
122+ withAnimation(.easeOut(duration: 1.6).repeatForever(autoreverses: false)) { on = true }
123+ }
124+ .accessibilityHidden(true)
125+ }
126+}
127+
128+// MARK: - Pulsations radar au centre de la carte (la caméra suit le marcheur,
129+// l'utilisateur est donc toujours au centre de l'écran)
130+
131+struct RadarPulse: View {
132+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
133+
134+ var body: some View {
135+ if reduceMotion {
136+ Circle()
137+ .stroke(KATheme.lime.opacity(0.5), lineWidth: 2)
138+ .frame(width: 160, height: 160)
139+ .allowsHitTesting(false)
140+ } else {
141+ TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in
142+ let t = tl.date.timeIntervalSinceReferenceDate
143+ ZStack {
144+ ring(t, phase: 0.0)
145+ ring(t, phase: 0.5)
146+ }
147+ }
148+ .allowsHitTesting(false)
149+ .accessibilityHidden(true)
150+ }
151+ }
152+
153+ private func ring(_ t: Double, phase: Double) -> some View {
154+ // progression 0→1 toutes les 2,4 s, décalée par anneau
155+ let p = ((t / 2.4) + phase).truncatingRemainder(dividingBy: 1)
156+ return Circle()
157+ .stroke(KATheme.lime.opacity(0.75 * (1 - p)), lineWidth: 2.5 * (1 - p) + 0.5)
158+ .background(Circle().fill(KATheme.lime.opacity(0.06 * (1 - p))))
159+ .frame(width: 40 + 260 * p, height: 40 + 260 * p)
160+ }
161+}
162+
163+// MARK: - LA carte pop-up : photo, prix, distance & direction EN DIRECT
164+
165+struct DiscoverCard: View {
166+ let poi: POIItem
167+ @EnvironmentObject var model: TrajetModel
168+ @Environment(\.colorScheme) private var scheme
169+
170+ private var universe: Universe? {
171+ Ecosystem.universe(poi.kind == .louka ? "lou-ka" : "immo-ka")
172+ }
173+ private var accent: Color { universe?.accent ?? KATheme.green }
174+
175+ var body: some View {
176+ VStack(spacing: 0) {
177+ photo
178+ VStack(alignment: .leading, spacing: 8) {
179+ HStack(alignment: .top, spacing: 8) {
180+ VStack(alignment: .leading, spacing: 3) {
181+ if let price = poi.priceLabel {
182+ Text(price)
183+ .font(KAFont.display(22))
184+ .foregroundStyle(accent)
185+ .lineLimit(1)
186+ .minimumScaleFactor(0.6)
187+ }
188+ Text(poi.name)
189+ .font(.system(.subheadline, design: .rounded, weight: .bold))
190+ .foregroundStyle(KATheme.ink(scheme))
191+ .lineLimit(2)
192+ if let extra = poi.extra {
193+ Text(extra)
194+ .font(KAFont.mono(9, bold: false))
195+ .foregroundStyle(KATheme.ink2(scheme))
196+ .lineLimit(1)
197+ }
198+ if !poi.address.isEmpty {
199+ Text(poi.address)
200+ .font(.caption)
201+ .foregroundStyle(KATheme.ink2(scheme))
202+ .lineLimit(1)
203+ }
204+ }
205+ Spacer(minLength: 6)
206+ DistanceBadge(target: poi.coordinate, accent: accent)
207+ }
208+
209+ HStack(spacing: 8) {
210+ if let url = poi.url {
211+ Link(destination: url) {
212+ Label("Voir l'annonce", systemImage: "arrow.up.right.square.fill")
213+ .font(.system(.caption, design: .rounded, weight: .bold))
214+ .frame(maxWidth: .infinity)
215+ .padding(.vertical, 11)
216+ .background(accent, in: RoundedRectangle(cornerRadius: 12))
217+ .foregroundStyle(.white)
218+ }
219+ }
220+ Button {
221+ Haptics.tap()
222+ model.walkTo(poi)
223+ } label: {
224+ Label("Y aller", systemImage: "figure.walk")
225+ .font(.system(.caption, design: .rounded, weight: .bold))
226+ .frame(maxWidth: .infinity)
227+ .padding(.vertical, 11)
228+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12))
229+ .foregroundStyle(KATheme.lime)
230+ }
231+ Button {
232+ Haptics.tap()
233+ model.dismissDiscoverCard()
234+ } label: {
235+ Image(systemName: model.discoverQueue.isEmpty ? "xmark" : "arrow.right")
236+ .font(.system(size: 15, weight: .bold))
237+ .frame(width: 42, height: 40)
238+ .background(KATheme.paper(scheme), in: RoundedRectangle(cornerRadius: 12))
239+ .foregroundStyle(KATheme.ink(scheme))
240+ .overlay(alignment: .topTrailing) {
241+ if !model.discoverQueue.isEmpty {
242+ Text("\(model.discoverQueue.count)")
243+ .font(KAFont.mono(9))
244+ .padding(.horizontal, 5).padding(.vertical, 2)
245+ .background(accent, in: Capsule())
246+ .foregroundStyle(.white)
247+ .offset(x: 6, y: -6)
248+ }
249+ }
250+ }
251+ .accessibilityLabel(model.discoverQueue.isEmpty
252+ ? "Fermer la fiche"
253+ : "Fiche suivante — \(model.discoverQueue.count) en attente")
254+ }
255+ }
256+ .padding(14)
257+ }
258+ .background(KATheme.surface(scheme))
259+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
260+ .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
261+ .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))
262+ .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
263+ .fill(accent.opacity(scheme == .dark ? 0.35 : 1))
264+ .offset(x: 6, y: 6))
265+ .padding(.horizontal, 14)
266+ .padding(.bottom, 8)
267+ .transition(.asymmetric(
268+ insertion: .move(edge: .bottom).combined(with: .opacity).combined(with: .scale(scale: 0.85)),
269+ removal: .move(edge: .bottom).combined(with: .opacity)))
270+ .id(poi.id) // chaque trouvaille rejoue l'animation d'entrée
271+ }
272+
273+ @ViewBuilder
274+ private var photo: some View {
275+ ZStack(alignment: .topLeading) {
276+ KAImage(url: poi.imageURL, accent: accent, symbol: poi.kind.icon)
277+ .frame(height: poi.imageURL == nil ? 56 : 148)
278+ .frame(maxWidth: .infinity)
279+ .clipped()
280+ HStack(spacing: 6) {
281+ KAChip(text: poi.kind == .louka ? "Lou·Ka — à louer" : "Immo·Ka — à vendre",
282+ accent: accent)
283+ .background(.thinMaterial, in: Capsule())
284+ }
285+ .padding(8)
286+ }
287+ }
288+}
289+
290+/// Distance + flèche de direction vers l'annonce, recalculées à CHAQUE fix GPS.
291+struct DistanceBadge: View {
292+ let target: CLLocationCoordinate2D
293+ var accent: Color
294+ @EnvironmentObject var model: TrajetModel
295+
296+ var body: some View {
297+ let pos = model.locator.location
298+ let d = pos.map { TrajetModel.meters($0, target) }
299+ let bearing = pos.map { TrajetModel.bearing(from: $0, to: target) }
300+ VStack(spacing: 3) {
301+ Image(systemName: "location.north.fill")
302+ .font(.system(size: 16, weight: .bold))
303+ .foregroundStyle(accent)
304+ .rotationEffect(.degrees(bearing ?? 0))
305+ .animation(.spring(response: 0.5, dampingFraction: 0.8), value: bearing ?? 0)
306+ Text(d.map { Route.format(meters: $0) } ?? "—")
307+ .font(KAFont.mono(10))
308+ .contentTransition(.numericText())
309+ }
310+ .frame(width: 58, height: 58)
311+ .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
312+ .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
313+ .strokeBorder(accent.opacity(0.4), lineWidth: 1))
314+ .accessibilityLabel(d.map { "À \(Route.format(meters: $0)) d'ici" } ?? "Distance inconnue")
315+ }
316+}
317+
318+// MARK: - Bouton d'entrée (pilule au-dessus du dock, mode carte normal)
319+
320+struct DiscoverEntryButton: View {
321+ @EnvironmentObject var model: TrajetModel
322+
323+ var body: some View {
324+ Button {
325+ Haptics.rigid()
326+ withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) {
327+ model.startDiscover()
328+ }
329+ } label: {
330+ HStack(spacing: 7) {
331+ Image(systemName: "sensor.tag.radiowaves.forward.fill")
332+ .font(.system(size: 14, weight: .bold))
333+ Text("Découvrir")
334+ .font(.system(.subheadline, design: .rounded, weight: .bold))
335+ }
336+ .padding(.horizontal, 18)
337+ .padding(.vertical, 11)
338+ .background(KATheme.inkLight, in: Capsule())
339+ .foregroundStyle(KATheme.lime)
340+ .overlay(Capsule().strokeBorder(KATheme.lime.opacity(0.5), lineWidth: 1.5))
341+ .background(Capsule().fill(KATheme.lime.opacity(0.85)).offset(x: 4, y: 4))
342+ .shadow(color: .black.opacity(0.2), radius: 8, y: 4)
343+ }
344+ .accessibilityLabel("Activer le mode Découvrir : les logements et propriétés proches apparaissent en marchant")
345+ }
346+}
modified KA/Features/Trajet/LocationManager.swift +3 −0
@@ -9,6 +9,7 @@ import CoreLocation
9 final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { 9 final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
10 @Published var location: CLLocationCoordinate2D? 10 @Published var location: CLLocationCoordinate2D?
11 @Published var speedKmh: Double? // vitesse GPS en km/h (nil si inconnue) 11 @Published var speedKmh: Double? // vitesse GPS en km/h (nil si inconnue)
12+ @Published var course: Double? // cap GPS en degrés vrais (nil si inconnu)
12 @Published var authorized = false 13 @Published var authorized = false
13 14
14 private let manager = CLLocationManager() 15 private let manager = CLLocationManager()
@@ -39,9 +40,11 @@ final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelega
39 guard let loc = locations.last else { return } 40 guard let loc = locations.last else { return }
40 let c = loc.coordinate 41 let c = loc.coordinate
41 let kmh = loc.speed >= 0 ? loc.speed * 3.6 : nil 42 let kmh = loc.speed >= 0 ? loc.speed * 3.6 : nil
43+ let crs = loc.course >= 0 ? loc.course : nil
42 Task { @MainActor in 44 Task { @MainActor in
43 self.location = c 45 self.location = c
44 self.speedKmh = kmh 46 self.speedKmh = kmh
47+ self.course = crs
45 } 48 }
46 } 49 }
47 50
modified KA/Features/Trajet/POI.swift +16 −3
@@ -48,13 +48,14 @@ struct POIItem: Identifiable {
48 let coordinate: CLLocationCoordinate2D 48 let coordinate: CLLocationCoordinate2D
49 let url: URL? // fiche de l'annonce sur le site 49 let url: URL? // fiche de l'annonce sur le site
50 let extra: String? // détail (ex. ordinaire/super/diesel + source) 50 let extra: String? // détail (ex. ordinaire/super/diesel + source)
51+ let imageURL: URL? // photo de l'annonce (mode Découvrir)
51 52
52 init(id: String, kind: POIKind, name: String, address: String, 53 init(id: String, kind: POIKind, name: String, address: String,
53 priceLabel: String?, coordinate: CLLocationCoordinate2D, 54 priceLabel: String?, coordinate: CLLocationCoordinate2D,
54− url: URL?, extra: String? = nil) { 55+ url: URL?, extra: String? = nil, imageURL: URL? = nil) {
55 self.id = id; self.kind = kind; self.name = name; self.address = address 56 self.id = id; self.kind = kind; self.name = name; self.address = address
56 self.priceLabel = priceLabel; self.coordinate = coordinate 57 self.priceLabel = priceLabel; self.coordinate = coordinate
57− self.url = url; self.extra = extra 58+ self.url = url; self.extra = extra; self.imageURL = imageURL
58 } 59 }
59 } 60 }
60 61
@@ -349,13 +350,25 @@ enum POIService {
349 let addr = kind == .jobka 350 let addr = kind == .jobka
350 ? [props["employer"] as? String, props["city"] as? String] 351 ? [props["employer"] as? String, props["city"] as? String]
351 : [props["address"] as? String, props["city"] as? String] 352 : [props["address"] as? String, props["city"] as? String]
353+ // détails riches pour la carte Découvrir (type, secteur, pièces)
354+ let bits = [
355+ props["unit_type"] as? String,
356+ props["property_type"] as? String,
357+ (props["bedrooms"] as? Double).map { "\(Int($0)) ch." },
358+ (props["area_sqft"] as? Double).flatMap { $0 > 50 ? "\(Int($0)) pi²" : nil },
359+ props["sector"] as? String,
360+ ].compactMap { $0 }.filter { !$0.isEmpty }
352 return POIItem(id: "\(kind.rawValue):\(uid)", 361 return POIItem(id: "\(kind.rawValue):\(uid)",
353 kind: kind, 362 kind: kind,
354 name: title, 363 name: title,
355 address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "), 364 address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
356 priceLabel: price, 365 priceLabel: price,
357 coordinate: .init(latitude: coords[1], longitude: coords[0]), 366 coordinate: .init(latitude: coords[1], longitude: coords[0]),
358− url: URL(string: "https://\(host)\(detail)\(uid)")) 367+ url: URL(string: "https://\(host)\(detail)\(uid)"),
368+ extra: bits.isEmpty ? nil : bits.joined(separator: " · "),
369+ imageURL: (props["image"] as? String).flatMap {
370+ $0.hasPrefix("http") ? URL(string: $0) : nil
371+ })
359 } 372 }
360 } 373 }
361 } 374 }
modified KA/Features/Trajet/TrajetMapView.swift +21 −16
@@ -9,11 +9,14 @@ import MapKit
9 9
10 // KaMapStyle est déclaré dans KaMapLibre.swift (même style Liberty partagé). 10 // KaMapStyle est déclaré dans KaMapLibre.swift (même style Liberty partagé).
11 11
12−/// Caméra de conduite : derrière la voiture, inclinée, orientée selon la route. 12+/// Caméra de suivi : derrière la voiture (conduite) ou au-dessus du marcheur
13+/// (mode Découvrir) — inclinée, orientée selon la route ou le cap GPS.
13 struct NavCamera: Equatable { 14 struct NavCamera: Equatable {
14 var center: CLLocationCoordinate2D 15 var center: CLLocationCoordinate2D
15 var heading: Double 16 var heading: Double
16 var epoch: Int 17 var epoch: Int
18+ var altitude: Double = 260
19+ var pitch: CGFloat = 62
17 static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch } 20 static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch }
18 } 21 }
19 22
@@ -72,14 +75,27 @@ struct TrajetMapView: UIViewRepresentable {
72 let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways) 75 let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
73 if map.showsUserLocation != allowed { map.showsUserLocation = allowed } 76 if map.showsUserLocation != allowed { map.showsUserLocation = allowed }
74 77
75− // caméra de conduite : suit la position réelle de la voiture, 78+ // marqueurs des couches POI — synchronisés AVANT le retour anticipé de
76− // vue 3D inclinée orientée dans le sens de la route 79+ // la caméra de suivi : le mode Découvrir garde la caméra active en
80+ // continu et a besoin de ses pilules d'annonces
81+ let poiIDs = Set(pois.map(\.id))
82+ if context.coordinator.lastPOIIDs != poiIDs
83+ || context.coordinator.lastPOISelectedID != selectedPOIID {
84+ context.coordinator.lastPOIIDs = poiIDs
85+ context.coordinator.lastPOISelectedID = selectedPOIID
86+ let old = (map.annotations ?? []).compactMap { $0 as? POIAnnotation }
87+ map.removeAnnotations(old)
88+ map.addAnnotations(pois.map(POIAnnotation.init))
89+ }
90+
91+ // caméra de suivi : position réelle (voiture en conduite, marcheur en
92+ // Découvrir), vue 3D inclinée orientée selon la route ou le cap
77 if let nav = navCamera { 93 if let nav = navCamera {
78 if context.coordinator.lastNavEpoch != nav.epoch { 94 if context.coordinator.lastNavEpoch != nav.epoch {
79 context.coordinator.lastNavEpoch = nav.epoch 95 context.coordinator.lastNavEpoch = nav.epoch
80 let cam = MLNMapCamera(lookingAtCenter: nav.center, 96 let cam = MLNMapCamera(lookingAtCenter: nav.center,
81− altitude: 260, 97+ altitude: nav.altitude,
82− pitch: 62, 98+ pitch: nav.pitch,
83 heading: nav.heading) 99 heading: nav.heading)
84 map.setCamera(cam, withDuration: 0.9, 100 map.setCamera(cam, withDuration: 0.9,
85 animationTimingFunction: CAMediaTimingFunction(name: .linear)) 101 animationTimingFunction: CAMediaTimingFunction(name: .linear))
@@ -121,17 +137,6 @@ struct TrajetMapView: UIViewRepresentable {
121 context.coordinator.lastRouteSignature = signature 137 context.coordinator.lastRouteSignature = signature
122 context.coordinator.syncRoutes(on: map) 138 context.coordinator.syncRoutes(on: map)
123 } 139 }
124−
125− // marqueurs des couches POI (stations, commerces, annonces, restos, sorties)
126− let poiIDs = Set(pois.map(\.id))
127− if context.coordinator.lastPOIIDs != poiIDs
128− || context.coordinator.lastPOISelectedID != selectedPOIID {
129− context.coordinator.lastPOIIDs = poiIDs
130− context.coordinator.lastPOISelectedID = selectedPOIID
131− let old = (map.annotations ?? []).compactMap { $0 as? POIAnnotation }
132− map.removeAnnotations(old)
133− map.addAnnotations(pois.map(POIAnnotation.init))
134− }
135 } 140 }
136 141
137 func makeCoordinator() -> Coordinator { Coordinator(self) } 142 func makeCoordinator() -> Coordinator { Coordinator(self) }
modified KA/Features/Trajet/TrajetModel.swift +130 −0
@@ -37,6 +37,18 @@ final class TrajetModel: ObservableObject {
37 @Published var stepDistanceText = "" 37 @Published var stepDistanceText = ""
38 private var navEpoch = 0 38 private var navEpoch = 0
39 39
40+ // mode Découvrir — radar piéton : les annonces Lou-Ka / Immo-Ka
41+ // proches de la position POPPENT en marchant
42+ @Published var discovering = false
43+ @Published var discoverPOIs: [POIItem] = []
44+ @Published var discoverCard: POIItem?
45+ @Published private(set) var discoverQueue: [POIItem] = []
46+ @Published var discoverRadius: Double = 250 // m
47+ @Published var discoverKinds: Set<POIKind> = [.louka, .immoka]
48+ private var discoverSeen: Set<String> = []
49+ private var discoverLastFetch: CLLocationCoordinate2D?
50+ private var discoverGeneration = 0
51+
40 // caméra programmée (epoch → la carte n'obéit qu'aux changements voulus) 52 // caméra programmée (epoch → la carte n'obéit qu'aux changements voulus)
41 @Published var programRegion = MKCoordinateRegion( 53 @Published var programRegion = MKCoordinateRegion(
42 center: .init(latitude: 46.8139, longitude: -71.2080), // Québec 54 center: .init(latitude: 46.8139, longitude: -71.2080), // Québec
@@ -70,6 +82,7 @@ final class TrajetModel: ObservableObject {
70 self.center(on: c, span: 0.03) 82 self.center(on: c, span: 0.03)
71 } 83 }
72 if self.navigating { self.updateNav() } 84 if self.navigating { self.updateNav() }
85+ if self.discovering { self.updateDiscover(at: c) }
73 } 86 }
74 .store(in: &cancellables) 87 .store(in: &cancellables)
75 88
@@ -91,6 +104,7 @@ final class TrajetModel: ObservableObject {
91 } 104 }
92 105
93 private func refreshPOIs() { 106 private func refreshPOIs() {
107+ guard !discovering else { return } // Découvrir pilote lui-même les marqueurs
94 poiGeneration += 1 108 poiGeneration += 1
95 let gen = poiGeneration 109 let gen = poiGeneration
96 guard !layers.isEmpty else { pois = []; return } 110 guard !layers.isEmpty else { pois = []; return }
@@ -269,6 +283,122 @@ final class TrajetModel: ObservableObject {
269 selectedPOI = nil 283 selectedPOI = nil
270 } 284 }
271 285
286+ // MARK: mode Découvrir (radar piéton géolocalisé)
287+
288+ func startDiscover() {
289+ stopNavigation()
290+ routes = []; routeIndex = 0; showSteps = false; errorMessage = nil
291+ selected = nil; selectedPOI = nil
292+ query = ""; results = []
293+ discovering = true
294+ discoverSeen = []; discoverQueue = []; discoverCard = nil
295+ discoverPOIs = []; discoverLastFetch = nil
296+ locator.request()
297+ if let c = locator.location { updateDiscover(at: c) }
298+ }
299+
300+ func stopDiscover() {
301+ guard discovering else { return }
302+ discovering = false
303+ discoverCard = nil; discoverQueue = []; discoverPOIs = []
304+ discoverGeneration += 1 // invalide les fetchs en vol
305+ navCamera = nil
306+ pois = []
307+ if let c = locator.location { center(on: c, span: 0.02) }
308+ refreshPOIs() // redonne la main aux couches classiques
309+ }
310+
311+ func toggleDiscoverKind(_ k: POIKind) {
312+ if discoverKinds.contains(k) {
313+ guard discoverKinds.count > 1 else { return } // toujours au moins une couche
314+ discoverKinds.remove(k)
315+ } else {
316+ discoverKinds.insert(k)
317+ }
318+ discoverLastFetch = nil
319+ if let c = locator.location { updateDiscover(at: c) }
320+ }
321+
322+ func setDiscoverRadius(_ r: Double) {
323+ discoverRadius = r
324+ if let c = locator.location { scanProximity(at: c) }
325+ }
326+
327+ /// Tap sur un marqueur en mode Découvrir : sa carte pop directement.
328+ func showDiscoverCard(_ poi: POIItem) {
329+ discoverSeen.insert(poi.id)
330+ discoverQueue.removeAll { $0.id == poi.id }
331+ discoverCard = poi
332+ }
333+
334+ func dismissDiscoverCard() {
335+ if discoverQueue.isEmpty {
336+ discoverCard = nil
337+ } else {
338+ discoverCard = discoverQueue.removeFirst()
339+ }
340+ }
341+
342+ /// Quitte Découvrir et lance l'itinéraire À PIED vers l'annonce.
343+ func walkTo(_ poi: POIItem) {
344+ stopDiscover()
345+ selectedPOI = poi
346+ selected = Place(id: poi.id, name: poi.name,
347+ address: poi.address.isEmpty ? (poi.priceLabel ?? "") : poi.address,
348+ coordinate: poi.coordinate)
349+ mode = .marche
350+ computeRoute()
351+ }
352+
353+ // caméra au-dessus du marcheur (orientée selon le cap GPS quand on avance)
354+ // + rechargement des annonces tous les ~200 m + détection de proximité
355+ private func updateDiscover(at c: CLLocationCoordinate2D) {
356+ navEpoch += 1
357+ let moving = (locator.speedKmh ?? 0) > 1.5
358+ let heading = (moving ? locator.course : nil) ?? 0
359+ navCamera = NavCamera(center: c, heading: heading, epoch: navEpoch,
360+ altitude: 700, pitch: 48)
361+
362+ if discoverLastFetch.map({ Self.meters($0, c) > 200 }) ?? true {
363+ discoverLastFetch = c
364+ discoverGeneration += 1
365+ let gen = discoverGeneration
366+ let region = MKCoordinateRegion(
367+ center: c,
368+ span: .init(latitudeDelta: 0.014, longitudeDelta: 0.018)) // ≈ 1,5 km
369+ let kinds = discoverKinds
370+ Task {
371+ var all: [POIItem] = []
372+ await withTaskGroup(of: [POIItem].self) { group in
373+ for k in kinds { group.addTask { await POIService.fetch(k, region: region) } }
374+ for await items in group { all.append(contentsOf: items) }
375+ }
376+ guard self.discovering, gen == self.discoverGeneration else { return }
377+ self.discoverPOIs = all
378+ self.pois = all
379+ self.scanProximity(at: self.locator.location ?? c)
380+ }
381+ } else {
382+ scanProximity(at: c)
383+ }
384+ }
385+
386+ private func scanProximity(at c: CLLocationCoordinate2D) {
387+ let near = discoverPOIs
388+ .filter { !discoverSeen.contains($0.id) && Self.meters(c, $0.coordinate) <= discoverRadius }
389+ .sorted { Self.meters(c, $0.coordinate) < Self.meters(c, $1.coordinate) }
390+ guard !near.isEmpty else { return }
391+ discoverSeen.formUnion(near.map(\.id))
392+ if discoverCard == nil, let first = near.first {
393+ discoverCard = first
394+ discoverQueue.append(contentsOf: near.dropFirst())
395+ Haptics.success()
396+ } else {
397+ discoverQueue.append(contentsOf: near)
398+ Haptics.tap()
399+ }
400+ }
401+
272 // MARK: caméra 402 // MARK: caméra
273 403
274 func center(on c: CLLocationCoordinate2D, span: Double) { 404 func center(on c: CLLocationCoordinate2D, span: Double) {
modified KA/Features/Trajet/TrajetView.swift +39 −4
@@ -23,28 +23,63 @@ struct TrajetView: View {
23 selectedPOIID: model.selectedPOI?.id, 23 selectedPOIID: model.selectedPOI?.id,
24 onLongPress: { model.dropPin(at: $0) }, 24 onLongPress: { model.dropPin(at: $0) },
25 onRegionChange: { model.regionSubject.send($0) }, 25 onRegionChange: { model.regionSubject.send($0) },
26− onSelectPOI: { model.selectPOI($0) }) 26+ onSelectPOI: { poi in
27+ if model.discovering { model.showDiscoverCard(poi) }
28+ else { model.selectPOI(poi) }
29+ })
27 .ignoresSafeArea() 30 .ignoresSafeArea()
28 31
32+ // pulsations radar — la caméra suit le marcheur, donc l'utilisateur
33+ // est toujours au centre de l'écran
34+ if model.discovering { RadarPulse() }
35+
29 VStack(spacing: 0) { 36 VStack(spacing: 0) {
30 if model.navigating { 37 if model.navigating {
31 navBanner 38 navBanner
39+ } else if model.discovering {
40+ DiscoverHUD()
32 } else { 41 } else {
33 header 42 header
34 if !model.results.isEmpty && searchFocused { resultsList } 43 if !model.results.isEmpty && searchFocused { resultsList }
35 else { layerChips } 44 else { layerChips }
36 } 45 }
37 Spacer() 46 Spacer()
38− if model.navigating { navBottomBar } else { bottomPanel } 47+ if model.navigating {
48+ navBottomBar
49+ } else if model.discovering {
50+ if let card = model.discoverCard {
51+ DiscoverCard(poi: card)
52+ }
53+ } else {
54+ bottomPanel
55+ }
39 } 56 }
40 57
41− if !model.navigating { floatingButtons } 58+ if !model.navigating && !model.discovering { floatingButtons }
59+
60+ // bouton d'entrée du mode Découvrir (carte libre, rien de sélectionné)
61+ if !model.navigating && !model.discovering && model.selected == nil {
62+ VStack {
63+ Spacer()
64+ DiscoverEntryButton()
65+ .padding(.bottom, 10)
66+ }
67+ .transition(.move(edge: .bottom).combined(with: .opacity))
68+ }
69+ }
70+ .onAppear {
71+ model.locator.request()
72+ // pilotage debug/captures : lance Découvrir dès l'ouverture
73+ if UserDefaults.standard.bool(forKey: "ka.debug.discover"), !model.discovering {
74+ model.startDiscover()
75+ }
42 } 76 }
43− .onAppear { model.locator.request() }
44 .sheet(isPresented: $model.showSteps) { StepsSheet() } 77 .sheet(isPresented: $model.showSteps) { StepsSheet() }
45 .animation(.spring(duration: 0.3), value: model.selected) 78 .animation(.spring(duration: 0.3), value: model.selected)
46 .animation(.spring(duration: 0.3), value: model.routes.count) 79 .animation(.spring(duration: 0.3), value: model.routes.count)
47 .animation(.spring(duration: 0.3), value: model.navigating) 80 .animation(.spring(duration: 0.3), value: model.navigating)
81+ .animation(.spring(duration: 0.35), value: model.discovering)
82+ .animation(.spring(response: 0.45, dampingFraction: 0.75), value: model.discoverCard?.id)
48 } 83 }
49 84
50 // MARK: couches (stations, commerces, annonces Lou-Ka / Immo-Ka) 85 // MARK: couches (stations, commerces, annonces Lou-Ka / Immo-Ka)
modified README.md +9 −0
@@ -23,6 +23,15 @@ plateformes alimentent chaque écran).
23 Lou·Ka, Immo·Ka, Resto·Ka, Sorti·Ka, Job·Ka, Auto·Ka, Fabri·Ka) et la 23 Lou·Ka, Immo·Ka, Resto·Ka, Sorti·Ka, Job·Ka, Auto·Ka, Fabri·Ka) et la
24 cartographie signature Ka (eau vert forêt, autoroutes lime). Le dock 24 cartographie signature Ka (eau vert forêt, autoroutes lime). Le dock
25 s'efface en mode conduite. Sources sous `KA/Features/Trajet/`. 25 s'efface en mode conduite. Sources sous `KA/Features/Trajet/`.
26+- **Mode DÉCOUVRIR** (dans Trajet) : un radar piéton géolocalisé — on marche,
27+ la caméra 3D suit le cap GPS, et dès qu'un logement à louer (Lou·Ka) ou une
28+ propriété à vendre (Immo·Ka) entre dans le rayon (100/250/500 m), sa carte
29+ **pop** avec photo, prix, type/pièces/secteur, distance et flèche de
30+ direction recalculées à chaque fix GPS, haptique, et file « suivant » quand
31+ plusieurs trouvailles. Pilules de prix live sur la carte, pulsations radar,
32+ HUD encre/lime avec bascules de couches et rayon. Le dock s'efface
33+ (plein écran). Chaque annonce ne pop qu'une fois par session.
34+ Debug/captures : `defaults write com.groupeka.ka ka.debug.discover -bool true`.
26 - **Dock flottant signature** (remplace la tab bar système) : capsule encre à 35 - **Dock flottant signature** (remplace la tab bar système) : capsule encre à
27 ombre dure lime, pastille active lime en matchedGeometry, KA Agent intégré 36 ombre dure lime, pastille active lime en matchedGeometry, KA Agent intégré
28 au bout du dock, badge d'alertes sur Profil. 37 au bout du dock, badge d'alertes sur Profil.
29 38
Menu

Theme