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>
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 | |