SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
29 days agolast push
Swift 100%
17.5 KB · 406 lines swift
Raw Blame History
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