SPB Git forge

spb/ka-ios

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

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 1 mo ago (Aug 25, 2026) parent 0728c47

8 changed files +568 −25

modified KA/App/KAApp.swift +4 −2
@@ -63,14 +63,16 @@ struct RootView: View {
63 63 }
64 64 .tint(KATheme.green)
65 65 .safeAreaInset(edge: .bottom) {
66 // le dock s'efface en mode conduite (Trajet plein écran)
67 if !trajet.navigating {
66 + // le dock s'efface dans les modes immersifs de Trajet
67 + // (conduite et Découvrir plein écran)
68 + if !trajet.navigating && !trajet.discovering {
68 69 KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true }
69 70 .padding(.bottom, 4)
70 71 .transition(.move(edge: .bottom).combined(with: .opacity))
71 72 }
72 73 }
73 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 76 .onChange(of: tab) { _, t in loaded.insert(t) }
75 77 .task { await recents.refreshAlerts() }
76 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 9 final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
10 10 @Published var location: CLLocationCoordinate2D?
11 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 13 @Published var authorized = false
13 14
14 15 private let manager = CLLocationManager()
@@ -39,9 +40,11 @@ final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelega
39 40 guard let loc = locations.last else { return }
40 41 let c = loc.coordinate
41 42 let kmh = loc.speed >= 0 ? loc.speed * 3.6 : nil
43 + let crs = loc.course >= 0 ? loc.course : nil
42 44 Task { @MainActor in
43 45 self.location = c
44 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 48 let coordinate: CLLocationCoordinate2D
49 49 let url: URL? // fiche de l'annonce sur le site
50 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 53 init(id: String, kind: POIKind, name: String, address: String,
53 54 priceLabel: String?, coordinate: CLLocationCoordinate2D,
54 url: URL?, extra: String? = nil) {
55 + url: URL?, extra: String? = nil, imageURL: URL? = nil) {
55 56 self.id = id; self.kind = kind; self.name = name; self.address = address
56 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 350 let addr = kind == .jobka
350 351 ? [props["employer"] as? String, props["city"] as? String]
351 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 361 return POIItem(id: "\(kind.rawValue):\(uid)",
353 362 kind: kind,
354 363 name: title,
355 364 address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
356 365 priceLabel: price,
357 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 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 14 struct NavCamera: Equatable {
14 15 var center: CLLocationCoordinate2D
15 16 var heading: Double
16 17 var epoch: Int
18 + var altitude: Double = 260
19 + var pitch: CGFloat = 62
17 20 static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch }
18 21 }
19 22
@@ -72,14 +75,27 @@ struct TrajetMapView: UIViewRepresentable {
72 75 let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
73 76 if map.showsUserLocation != allowed { map.showsUserLocation = allowed }
74 77
75 // caméra de conduite : suit la position réelle de la voiture,
76 // vue 3D inclinée orientée dans le sens de la route
78 + // marqueurs des couches POI — synchronisés AVANT le retour anticipé de
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 93 if let nav = navCamera {
78 94 if context.coordinator.lastNavEpoch != nav.epoch {
79 95 context.coordinator.lastNavEpoch = nav.epoch
80 96 let cam = MLNMapCamera(lookingAtCenter: nav.center,
81 altitude: 260,
82 pitch: 62,
97 + altitude: nav.altitude,
98 + pitch: nav.pitch,
83 99 heading: nav.heading)
84 100 map.setCamera(cam, withDuration: 0.9,
85 101 animationTimingFunction: CAMediaTimingFunction(name: .linear))
@@ -121,17 +137,6 @@ struct TrajetMapView: UIViewRepresentable {
121 137 context.coordinator.lastRouteSignature = signature
122 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 142 func makeCoordinator() -> Coordinator { Coordinator(self) }
modified KA/Features/Trajet/TrajetModel.swift +130 −0
@@ -37,6 +37,18 @@ final class TrajetModel: ObservableObject {
37 37 @Published var stepDistanceText = ""
38 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 52 // caméra programmée (epoch → la carte n'obéit qu'aux changements voulus)
41 53 @Published var programRegion = MKCoordinateRegion(
42 54 center: .init(latitude: 46.8139, longitude: -71.2080), // Québec
@@ -70,6 +82,7 @@ final class TrajetModel: ObservableObject {
70 82 self.center(on: c, span: 0.03)
71 83 }
72 84 if self.navigating { self.updateNav() }
85 + if self.discovering { self.updateDiscover(at: c) }
73 86 }
74 87 .store(in: &cancellables)
75 88
@@ -91,6 +104,7 @@ final class TrajetModel: ObservableObject {
91 104 }
92 105
93 106 private func refreshPOIs() {
107 + guard !discovering else { return } // Découvrir pilote lui-même les marqueurs
94 108 poiGeneration += 1
95 109 let gen = poiGeneration
96 110 guard !layers.isEmpty else { pois = []; return }
@@ -269,6 +283,122 @@ final class TrajetModel: ObservableObject {
269 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 402 // MARK: caméra
273 403
274 404 func center(on c: CLLocationCoordinate2D, span: Double) {
modified KA/Features/Trajet/TrajetView.swift +39 −4
@@ -23,28 +23,63 @@ struct TrajetView: View {
23 23 selectedPOIID: model.selectedPOI?.id,
24 24 onLongPress: { model.dropPin(at: $0) },
25 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 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 36 VStack(spacing: 0) {
30 37 if model.navigating {
31 38 navBanner
39 + } else if model.discovering {
40 + DiscoverHUD()
32 41 } else {
33 42 header
34 43 if !model.results.isEmpty && searchFocused { resultsList }
35 44 else { layerChips }
36 45 }
37 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 77 .sheet(isPresented: $model.showSteps) { StepsSheet() }
45 78 .animation(.spring(duration: 0.3), value: model.selected)
46 79 .animation(.spring(duration: 0.3), value: model.routes.count)
47 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 85 // MARK: couches (stations, commerces, annonces Lou-Ka / Immo-Ka)
modified README.md +9 −0
@@ -23,6 +23,15 @@ plateformes alimentent chaque écran).
23 23 Lou·Ka, Immo·Ka, Resto·Ka, Sorti·Ka, Job·Ka, Auto·Ka, Fabri·Ka) et la
24 24 cartographie signature Ka (eau vert forêt, autoroutes lime). Le dock
25 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 35 - **Dock flottant signature** (remplace la tab bar système) : capsule encre à
27 36 ombre dure lime, pastille active lime en matchedGeometry, KA Agent intégré
28 37 au bout du dock, badge d'alertes sur Profil.
29 38