// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // DiscoverView.swift — le mode DÉCOUVRIR : un radar piéton géolocalisé. // On marche, la caméra 3D suit, et dès qu'un logement à louer (Lou-Ka) ou // une propriété à vendre (Immo-Ka) entre dans le rayon, sa carte POP avec // photo, prix, distance et direction en direct. HUD encre/lime signature, // pulsations radar au centre, file « suivant » quand plusieurs trouvailles. import SwiftUI import CoreLocation // MARK: - HUD du haut (remplace la barre de recherche en mode Découvrir) struct DiscoverHUD: View { @EnvironmentObject var model: TrajetModel @Environment(\.colorScheme) private var scheme var body: some View { VStack(spacing: 10) { HStack(spacing: 10) { RadarIcon() VStack(alignment: .leading, spacing: 2) { Text("MODE DÉCOUVERTE") .font(KAFont.mono(10)) .foregroundStyle(KATheme.lime) Text(statusLine) .font(.system(.caption, design: .rounded, weight: .semibold)) .foregroundStyle(.white) .contentTransition(.numericText()) } Spacer() Button { Haptics.tap() model.stopDiscover() } label: { Image(systemName: "xmark") .font(.system(size: 14, weight: .bold)) .foregroundStyle(KATheme.inkLight) .frame(width: 32, height: 32) .background(KATheme.lime, in: Circle()) } .accessibilityLabel("Quitter le mode Découvrir") } HStack(spacing: 8) { kindChip(.louka) kindChip(.immoka) Spacer() radiusMenu } } .padding(14) .background(KATheme.inkLight.opacity(0.94), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(KATheme.lime.opacity(0.45), lineWidth: 1.5)) .shadow(color: .black.opacity(0.3), radius: 12, y: 5) .padding(.horizontal, 12) .padding(.top, 6) .transition(.move(edge: .top).combined(with: .opacity)) } private var statusLine: String { let n = model.discoverPOIs.count let q = model.discoverQueue.count var s = n == 0 ? "On scanne le quartier…" : "\(n) annonce\(n > 1 ? "s" : "") autour de vous" if q > 0 { s += " · \(q) en attente" } return s } private func kindChip(_ k: POIKind) -> some View { let on = model.discoverKinds.contains(k) return Button { Haptics.tap() model.toggleDiscoverKind(k) } label: { Label(k.label, systemImage: k.icon) .font(.system(.caption2, design: .rounded, weight: .bold)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(on ? KATheme.lime : .white.opacity(0.12), in: Capsule()) .foregroundStyle(on ? KATheme.inkLight : .white.opacity(0.8)) } .accessibilityLabel("\(on ? "Désactiver" : "Activer") la couche \(k.label)") } private var radiusMenu: some View { Menu { ForEach([100.0, 250.0, 500.0], id: \.self) { r in Button { model.setDiscoverRadius(r) } label: { Label("\(Int(r)) m", systemImage: model.discoverRadius == r ? "checkmark" : "circle.dashed") } } } label: { Label("\(Int(model.discoverRadius)) m", systemImage: "dot.radiowaves.left.and.right") .font(KAFont.mono(10)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(.white.opacity(0.12), in: Capsule()) .foregroundStyle(KATheme.lime) } .accessibilityLabel("Rayon de détection : \(Int(model.discoverRadius)) mètres") } } /// Petite icône radar qui pulse dans le HUD. struct RadarIcon: View { @State private var on = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { Circle().stroke(KATheme.lime.opacity(0.5), lineWidth: 1.5) .scaleEffect(on ? 1.5 : 0.7) .opacity(on ? 0 : 0.9) Image(systemName: "sensor.tag.radiowaves.forward.fill") .font(.system(size: 17, weight: .bold)) .foregroundStyle(KATheme.lime) } .frame(width: 34, height: 34) .onAppear { guard !reduceMotion else { return } withAnimation(.easeOut(duration: 1.6).repeatForever(autoreverses: false)) { on = true } } .accessibilityHidden(true) } } // MARK: - Pulsations radar au centre de la carte (la caméra suit le marcheur, // l'utilisateur est donc toujours au centre de l'écran) struct RadarPulse: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { Circle() .stroke(KATheme.lime.opacity(0.5), lineWidth: 2) .frame(width: 160, height: 160) .allowsHitTesting(false) } else { TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in let t = tl.date.timeIntervalSinceReferenceDate ZStack { ring(t, phase: 0.0) ring(t, phase: 0.5) } } .allowsHitTesting(false) .accessibilityHidden(true) } } private func ring(_ t: Double, phase: Double) -> some View { // progression 0→1 toutes les 2,4 s, décalée par anneau let p = ((t / 2.4) + phase).truncatingRemainder(dividingBy: 1) return Circle() .stroke(KATheme.lime.opacity(0.75 * (1 - p)), lineWidth: 2.5 * (1 - p) + 0.5) .background(Circle().fill(KATheme.lime.opacity(0.06 * (1 - p)))) .frame(width: 40 + 260 * p, height: 40 + 260 * p) } } // MARK: - LA carte pop-up : photo, prix, distance & direction EN DIRECT struct DiscoverCard: View { let poi: POIItem @EnvironmentObject var model: TrajetModel @Environment(\.colorScheme) private var scheme // estimation Vrai-Prix, chargée paresseusement (une requête par carte, // cache mémoire dans VraiPrixService — voir VraiPrixService.swift) @State private var vraiPrix: VraiPrixEstimate? private var universe: Universe? { Ecosystem.universe(poi.kind == .louka ? "lou-ka" : "immo-ka") } private var accent: Color { universe?.accent ?? KATheme.green } private var vpAccent: Color { Ecosystem.universe("vrai-prix")?.accent ?? KATheme.green } var body: some View { VStack(spacing: 0) { photo VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 3) { if let price = poi.priceLabel { Text(price) .font(KAFont.display(22)) .foregroundStyle(accent) .lineLimit(1) .minimumScaleFactor(0.6) } Text(poi.name) .font(.system(.subheadline, design: .rounded, weight: .bold)) .foregroundStyle(KATheme.ink(scheme)) .lineLimit(2) if let extra = poi.extra { Text(extra) .font(KAFont.mono(9, bold: false)) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(1) } if !poi.address.isEmpty { Text(poi.address) .font(.caption) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(1) } } Spacer(minLength: 6) DistanceBadge(target: poi.coordinate, accent: accent) } if let vp = vraiPrix { vraiPrixRow(vp) } HStack(spacing: 8) { if let url = poi.url { Link(destination: url) { Label("Voir l'annonce", systemImage: "arrow.up.right.square.fill") .font(.system(.caption, design: .rounded, weight: .bold)) .frame(maxWidth: .infinity) .padding(.vertical, 11) .background(accent, in: RoundedRectangle(cornerRadius: 12)) .foregroundStyle(.white) } } Button { Haptics.tap() model.walkTo(poi) } label: { Label("Y aller", systemImage: "figure.walk") .font(.system(.caption, design: .rounded, weight: .bold)) .frame(maxWidth: .infinity) .padding(.vertical, 11) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12)) .foregroundStyle(KATheme.lime) } Button { Haptics.tap() model.dismissDiscoverCard() } label: { Image(systemName: model.discoverQueue.isEmpty ? "xmark" : "arrow.right") .font(.system(size: 15, weight: .bold)) .frame(width: 42, height: 40) .background(KATheme.paper(scheme), in: RoundedRectangle(cornerRadius: 12)) .foregroundStyle(KATheme.ink(scheme)) .overlay(alignment: .topTrailing) { if !model.discoverQueue.isEmpty { Text("\(model.discoverQueue.count)") .font(KAFont.mono(9)) .padding(.horizontal, 5).padding(.vertical, 2) .background(accent, in: Capsule()) .foregroundStyle(.white) .offset(x: 6, y: -6) } } } .accessibilityLabel(model.discoverQueue.isEmpty ? "Fermer la fiche" : "Fiche suivante — \(model.discoverQueue.count) en attente") } } .padding(14) } .background(KATheme.surface(scheme)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) .background(RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(accent.opacity(scheme == .dark ? 0.35 : 1)) .offset(x: 6, y: 6)) .padding(.horizontal, 14) .padding(.bottom, 8) .transition(.asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity).combined(with: .scale(scale: 0.85)), removal: .move(edge: .bottom).combined(with: .opacity))) .id(poi.id) // chaque trouvaille rejoue l'animation d'entrée .task(id: poi.id) { guard poi.kind == .louka || poi.kind == .immoka else { return } let e = await VraiPrixService.shared.estimate(for: poi) guard !Task.isCancelled else { return } withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { vraiPrix = e } } } /// La ligne « estimation Vrai-Prix » : valeur du modèle hédonique de /// www.vrai-prix.com + écart du prix affiché quand l'adresse est appariée. @ViewBuilder private func vraiPrixRow(_ vp: VraiPrixEstimate) -> some View { let delta = poi.kind == .immoka ? vp.deltaPct(asking: poi.price) : nil HStack(spacing: 8) { Image(systemName: "chart.line.uptrend.xyaxis") .font(.system(size: 12, weight: .bold)) .foregroundStyle(vpAccent) Group { if poi.kind == .immoka { Text(vp.unitConfirmed ? "Vrai-Prix : " : vp.sameAddress ? "Vrai-Prix (immeuble) : " : "Vrai-Prix (secteur) : ") + Text("~\(vp.label)").bold() } else { Text(vp.sameAddress ? "Immeuble estimé " : "Secteur estimé ") + Text("~\(vp.label)").bold() + Text(" · Vrai-Prix") } } .font(.system(.caption, design: .rounded)) .foregroundStyle(KATheme.ink(scheme)) .lineLimit(1) .minimumScaleFactor(0.75) Spacer(minLength: 4) if let d = delta { Text("affiché \(d >= 0 ? "+" : "")\(d) %") .font(KAFont.mono(9)) .padding(.horizontal, 7).padding(.vertical, 3) .background((d > 5 ? Color.red : d < -5 ? Color.green : Color.gray) .opacity(0.15), in: Capsule()) .foregroundStyle(d > 5 ? .red : d < -5 ? .green : KATheme.ink2(scheme)) } } .padding(.horizontal, 10).padding(.vertical, 8) .background(vpAccent.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(vpAccent.opacity(0.25), lineWidth: 1)) .transition(.opacity.combined(with: .move(edge: .bottom))) .accessibilityElement(children: .combine) .accessibilityLabel( "Estimation Vrai-Prix : environ \(vp.label)" + (delta.map { ", prix affiché \($0 >= 0 ? "plus" : "moins") élevé de \(abs($0)) pour cent" } ?? "")) } @ViewBuilder private var photo: some View { ZStack(alignment: .topLeading) { KAImage(url: poi.imageURL, accent: accent, symbol: poi.kind.icon) .frame(height: poi.imageURL == nil ? 56 : 148) .frame(maxWidth: .infinity) .clipped() HStack(spacing: 6) { KAChip(text: poi.kind == .louka ? "Lou·Ka — à louer" : "Immo·Ka — à vendre", accent: accent) .background(.thinMaterial, in: Capsule()) } .padding(8) } } } /// Distance + flèche de direction vers l'annonce, recalculées à CHAQUE fix GPS. struct DistanceBadge: View { let target: CLLocationCoordinate2D var accent: Color @EnvironmentObject var model: TrajetModel var body: some View { let pos = model.locator.location let d = pos.map { TrajetModel.meters($0, target) } let bearing = pos.map { TrajetModel.bearing(from: $0, to: target) } VStack(spacing: 3) { Image(systemName: "location.north.fill") .font(.system(size: 16, weight: .bold)) .foregroundStyle(accent) .rotationEffect(.degrees(bearing ?? 0)) .animation(.spring(response: 0.5, dampingFraction: 0.8), value: bearing ?? 0) Text(d.map { Route.format(meters: $0) } ?? "—") .font(KAFont.mono(10)) .contentTransition(.numericText()) } .frame(width: 58, height: 58) .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(accent.opacity(0.4), lineWidth: 1)) .accessibilityLabel(d.map { "À \(Route.format(meters: $0)) d'ici" } ?? "Distance inconnue") } } // MARK: - Bouton d'entrée (pilule au-dessus du dock, mode carte normal) struct DiscoverEntryButton: View { @EnvironmentObject var model: TrajetModel var body: some View { Button { Haptics.rigid() withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) { model.startDiscover() } } label: { HStack(spacing: 7) { Image(systemName: "sensor.tag.radiowaves.forward.fill") .font(.system(size: 14, weight: .bold)) Text("Découvrir") .font(.system(.subheadline, design: .rounded, weight: .bold)) } .padding(.horizontal, 18) .padding(.vertical, 11) .background(KATheme.inkLight, in: Capsule()) .foregroundStyle(KATheme.lime) .overlay(Capsule().strokeBorder(KATheme.lime.opacity(0.5), lineWidth: 1.5)) .background(Capsule().fill(KATheme.lime.opacity(0.85)).offset(x: 4, y: 4)) .shadow(color: .black.opacity(0.2), radius: 8, y: 4) } .accessibilityLabel("Activer le mode Découvrir : les logements et propriétés proches apparaissent en marchant") } }