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