Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// 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) ou4// une propriété à vendre (Immo-Ka) entre dans le rayon, sa carte POP avec5// photo, prix, distance et direction en direct. HUD encre/lime signature,6// pulsations radar au centre, file « suivant » quand plusieurs trouvailles.7import SwiftUI8import CoreLocation910// MARK: - HUD du haut (remplace la barre de recherche en mode Découvrir)1112struct DiscoverHUD: View {13 @EnvironmentObject var model: TrajetModel14 @Environment(\.colorScheme) private var scheme1516 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 }4243 HStack(spacing: 8) {44 kindChip(.louka)45 kindChip(.immoka)46 Spacer()47 radiusMenu48 }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 }5960 private var statusLine: String {61 let n = model.discoverPOIs.count62 let q = model.discoverQueue.count63 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 s67 }6869 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 }8485 private var radiusMenu: some View {86 Menu {87 ForEach([100.0, 250.0, 500.0], id: \.self) { r in88 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}105106/// Petite icône radar qui pulse dans le HUD.107struct RadarIcon: View {108 @State private var on = false109 @Environment(\.accessibilityReduceMotion) private var reduceMotion110 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}127128// 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)130131struct RadarPulse: View {132 @Environment(\.accessibilityReduceMotion) private var reduceMotion133134 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 in142 let t = tl.date.timeIntervalSinceReferenceDate143 ZStack {144 ring(t, phase: 0.0)145 ring(t, phase: 0.5)146 }147 }148 .allowsHitTesting(false)149 .accessibilityHidden(true)150 }151 }152153 private func ring(_ t: Double, phase: Double) -> some View {154 // progression 0→1 toutes les 2,4 s, décalée par anneau155 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}162163// MARK: - LA carte pop-up : photo, prix, distance & direction EN DIRECT164165struct DiscoverCard: View {166 let poi: POIItem167 @EnvironmentObject var model: TrajetModel168 @Environment(\.colorScheme) private var scheme169 // estimation Vrai-Prix, chargée paresseusement (une requête par carte,170 // cache mémoire dans VraiPrixService — voir VraiPrixService.swift)171 @State private var vraiPrix: VraiPrixEstimate?172173 private var universe: Universe? {174 Ecosystem.universe(poi.kind == .louka ? "lou-ka" : "immo-ka")175 }176 private var accent: Color { universe?.accent ?? KATheme.green }177 private var vpAccent: Color { Ecosystem.universe("vrai-prix")?.accent ?? KATheme.green }178179 var body: some View {180 VStack(spacing: 0) {181 photo182 VStack(alignment: .leading, spacing: 8) {183 HStack(alignment: .top, spacing: 8) {184 VStack(alignment: .leading, spacing: 3) {185 if let price = poi.priceLabel {186 Text(price)187 .font(KAFont.display(22))188 .foregroundStyle(accent)189 .lineLimit(1)190 .minimumScaleFactor(0.6)191 }192 Text(poi.name)193 .font(.system(.subheadline, design: .rounded, weight: .bold))194 .foregroundStyle(KATheme.ink(scheme))195 .lineLimit(2)196 if let extra = poi.extra {197 Text(extra)198 .font(KAFont.mono(9, bold: false))199 .foregroundStyle(KATheme.ink2(scheme))200 .lineLimit(1)201 }202 if !poi.address.isEmpty {203 Text(poi.address)204 .font(.caption)205 .foregroundStyle(KATheme.ink2(scheme))206 .lineLimit(1)207 }208 }209 Spacer(minLength: 6)210 DistanceBadge(target: poi.coordinate, accent: accent)211 }212213 if let vp = vraiPrix {214 vraiPrixRow(vp)215 }216217 HStack(spacing: 8) {218 if let url = poi.url {219 Link(destination: url) {220 Label("Voir l'annonce", systemImage: "arrow.up.right.square.fill")221 .font(.system(.caption, design: .rounded, weight: .bold))222 .frame(maxWidth: .infinity)223 .padding(.vertical, 11)224 .background(accent, in: RoundedRectangle(cornerRadius: 12))225 .foregroundStyle(.white)226 }227 }228 Button {229 Haptics.tap()230 model.walkTo(poi)231 } label: {232 Label("Y aller", systemImage: "figure.walk")233 .font(.system(.caption, design: .rounded, weight: .bold))234 .frame(maxWidth: .infinity)235 .padding(.vertical, 11)236 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12))237 .foregroundStyle(KATheme.lime)238 }239 Button {240 Haptics.tap()241 model.dismissDiscoverCard()242 } label: {243 Image(systemName: model.discoverQueue.isEmpty ? "xmark" : "arrow.right")244 .font(.system(size: 15, weight: .bold))245 .frame(width: 42, height: 40)246 .background(KATheme.paper(scheme), in: RoundedRectangle(cornerRadius: 12))247 .foregroundStyle(KATheme.ink(scheme))248 .overlay(alignment: .topTrailing) {249 if !model.discoverQueue.isEmpty {250 Text("\(model.discoverQueue.count)")251 .font(KAFont.mono(9))252 .padding(.horizontal, 5).padding(.vertical, 2)253 .background(accent, in: Capsule())254 .foregroundStyle(.white)255 .offset(x: 6, y: -6)256 }257 }258 }259 .accessibilityLabel(model.discoverQueue.isEmpty260 ? "Fermer la fiche"261 : "Fiche suivante — \(model.discoverQueue.count) en attente")262 }263 }264 .padding(14)265 }266 .background(KATheme.surface(scheme))267 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))268 .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)269 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))270 .background(RoundedRectangle(cornerRadius: 18, style: .continuous)271 .fill(accent.opacity(scheme == .dark ? 0.35 : 1))272 .offset(x: 6, y: 6))273 .padding(.horizontal, 14)274 .padding(.bottom, 8)275 .transition(.asymmetric(276 insertion: .move(edge: .bottom).combined(with: .opacity).combined(with: .scale(scale: 0.85)),277 removal: .move(edge: .bottom).combined(with: .opacity)))278 .id(poi.id) // chaque trouvaille rejoue l'animation d'entrée279 .task(id: poi.id) {280 guard poi.kind == .louka || poi.kind == .immoka else { return }281 let e = await VraiPrixService.shared.estimate(for: poi)282 guard !Task.isCancelled else { return }283 withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { vraiPrix = e }284 }285 }286287 /// La ligne « estimation Vrai-Prix » : valeur du modèle hédonique de288 /// www.vrai-prix.com + écart du prix affiché quand l'adresse est appariée.289 @ViewBuilder290 private func vraiPrixRow(_ vp: VraiPrixEstimate) -> some View {291 let delta = poi.kind == .immoka ? vp.deltaPct(asking: poi.price) : nil292 HStack(spacing: 8) {293 Image(systemName: "chart.line.uptrend.xyaxis")294 .font(.system(size: 12, weight: .bold))295 .foregroundStyle(vpAccent)296 Group {297 if poi.kind == .immoka {298 Text(vp.unitConfirmed ? "Vrai-Prix : "299 : vp.sameAddress ? "Vrai-Prix (immeuble) : " : "Vrai-Prix (secteur) : ")300 + Text("~\(vp.label)").bold()301 } else {302 Text(vp.sameAddress ? "Immeuble estimé " : "Secteur estimé ")303 + Text("~\(vp.label)").bold()304 + Text(" · Vrai-Prix")305 }306 }307 .font(.system(.caption, design: .rounded))308 .foregroundStyle(KATheme.ink(scheme))309 .lineLimit(1)310 .minimumScaleFactor(0.75)311 Spacer(minLength: 4)312 if let d = delta {313 Text("affiché \(d >= 0 ? "+" : "")\(d) %")314 .font(KAFont.mono(9))315 .padding(.horizontal, 7).padding(.vertical, 3)316 .background((d > 5 ? Color.red : d < -5 ? Color.green : Color.gray)317 .opacity(0.15), in: Capsule())318 .foregroundStyle(d > 5 ? .red : d < -5 ? .green : KATheme.ink2(scheme))319 }320 }321 .padding(.horizontal, 10).padding(.vertical, 8)322 .background(vpAccent.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))323 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)324 .strokeBorder(vpAccent.opacity(0.25), lineWidth: 1))325 .transition(.opacity.combined(with: .move(edge: .bottom)))326 .accessibilityElement(children: .combine)327 .accessibilityLabel(328 "Estimation Vrai-Prix : environ \(vp.label)"329 + (delta.map { ", prix affiché \($0 >= 0 ? "plus" : "moins") élevé de \(abs($0)) pour cent" } ?? ""))330 }331332 @ViewBuilder333 private var photo: some View {334 ZStack(alignment: .topLeading) {335 KAImage(url: poi.imageURL, accent: accent, symbol: poi.kind.icon)336 .frame(height: poi.imageURL == nil ? 56 : 148)337 .frame(maxWidth: .infinity)338 .clipped()339 HStack(spacing: 6) {340 KAChip(text: poi.kind == .louka ? "Lou·Ka — à louer" : "Immo·Ka — à vendre",341 accent: accent)342 .background(.thinMaterial, in: Capsule())343 }344 .padding(8)345 }346 }347}348349/// Distance + flèche de direction vers l'annonce, recalculées à CHAQUE fix GPS.350struct DistanceBadge: View {351 let target: CLLocationCoordinate2D352 var accent: Color353 @EnvironmentObject var model: TrajetModel354355 var body: some View {356 let pos = model.locator.location357 let d = pos.map { TrajetModel.meters($0, target) }358 let bearing = pos.map { TrajetModel.bearing(from: $0, to: target) }359 VStack(spacing: 3) {360 Image(systemName: "location.north.fill")361 .font(.system(size: 16, weight: .bold))362 .foregroundStyle(accent)363 .rotationEffect(.degrees(bearing ?? 0))364 .animation(.spring(response: 0.5, dampingFraction: 0.8), value: bearing ?? 0)365 Text(d.map { Route.format(meters: $0) } ?? "—")366 .font(KAFont.mono(10))367 .contentTransition(.numericText())368 }369 .frame(width: 58, height: 58)370 .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))371 .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)372 .strokeBorder(accent.opacity(0.4), lineWidth: 1))373 .accessibilityLabel(d.map { "À \(Route.format(meters: $0)) d'ici" } ?? "Distance inconnue")374 }375}376377// MARK: - Bouton d'entrée (pilule au-dessus du dock, mode carte normal)378379struct DiscoverEntryButton: View {380 @EnvironmentObject var model: TrajetModel381382 var body: some View {383 Button {384 Haptics.rigid()385 withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) {386 model.startDiscover()387 }388 } label: {389 HStack(spacing: 7) {390 Image(systemName: "sensor.tag.radiowaves.forward.fill")391 .font(.system(size: 14, weight: .bold))392 Text("Découvrir")393 .font(.system(.subheadline, design: .rounded, weight: .bold))394 }395 .padding(.horizontal, 18)396 .padding(.vertical, 11)397 .background(KATheme.inkLight, in: Capsule())398 .foregroundStyle(KATheme.lime)399 .overlay(Capsule().strokeBorder(KATheme.lime.opacity(0.5), lineWidth: 1.5))400 .background(Capsule().fill(KATheme.lime.opacity(0.85)).offset(x: 4, y: 4))401 .shadow(color: .black.opacity(0.2), radius: 8, y: 4)402 }403 .accessibilityLabel("Activer le mode Découvrir : les logements et propriétés proches apparaissent en marchant")404 }405}406