SPB Git

spb/vrai-prix-ios Public

App iOS native de Vrai-Prix — estimation immobilière transparente pour le Québec (SwiftUI, MapKit, Swift Charts)

Swift 100%
7.4 KB · 199 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Recherche par carte : propriétés avoisinantes (MapKit), géolocalisation,3// fiche d'aperçu → estimation complète. Même API /api/nearby que le site.4import SwiftUI5import MapKit6import CoreLocation78struct MapSearchView: View {9    private static let montreal = MKCoordinateRegion(10        center: CLLocationCoordinate2D(latitude: 45.5421, longitude: -73.6646),11        span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.016)12    )13    /// Au-delà de cette étendue, on ne charge pas (trop dézoomé).14    private static let maxSpanLat = 0.0451516    @State private var position: MapCameraPosition = .region(Self.montreal)17    @State private var units: [NearbyUnit] = []18    @State private var selected: NearbyUnit?19    @State private var zoomedOut = false20    @State private var fetchTask: Task<Void, Never>?21    @State private var locationManager = CLLocationManager()2223    var body: some View {24        NavigationStack {25            ZStack(alignment: .bottom) {26                map27                overlayChip28                if let selected {29                    selectionCard(selected)30                        .transition(.move(edge: .bottom).combined(with: .opacity))31                }32            }33            .toolbar {34                ToolbarItem(placement: .principal) { Wordmark(size: 20) }35                ToolbarItem(placement: .topBarTrailing) {36                    Button {37                        locate()38                    } label: {39                        Image(systemName: "location.fill")40                            .font(.system(size: 14, weight: .bold))41                            .foregroundStyle(Color.vpLime)42                            .padding(8)43                            .background(Circle().fill(Color.vpInk))44                    }45                }46            }47            .navigationBarTitleDisplayMode(.inline)48            .toolbarBackground(Color.vpPaper, for: .navigationBar)49            .toolbarBackground(.visible, for: .navigationBar)50            .navigationDestination(for: SearchResult.self) { r in51                EstimateLoaderView(searchResult: r)52            }53        }54    }5556    // MARK: - Carte5758    private var map: some View {59        Map(position: $position) {60            UserAnnotation()61            ForEach(units) { u in62                Annotation("", coordinate: CLLocationCoordinate2D(latitude: u.lat, longitude: u.lng)) {63                    Button {64                        withAnimation(.snappy(duration: 0.2)) { selected = u }65                    } label: {66                        Circle()67                            .fill(selected?.id == u.id ? Color.vpLime : Color.vpInk)68                            .frame(width: 15, height: 15)69                            .overlay(Circle().stroke(selected?.id == u.id ? Color.vpInk : Color.vpLime, lineWidth: 2.5))70                            .shadow(color: Color.vpInk.opacity(0.35), radius: 2, y: 1)71                    }72                    .buttonStyle(.plain)73                    .accessibilityIdentifier("vp-dot")74                    .accessibilityLabel(u.adresse ?? "Propriété")75                }76            }77        }78        .mapStyle(.standard(pointsOfInterest: .excludingAll))79        .onMapCameraChange(frequency: .onEnd) { context in80            reload(region: context.region)81        }82        .task { reload(region: Self.montreal) } // chargement initial83        .ignoresSafeArea(edges: .bottom)84    }8586    private var overlayChip: some View {87        VStack {88            Text(zoomedOut89                 ? "Zoomez pour voir les propriétés"90                 : "\(units.count) propriétés · touchez un point")91                .font(.vpMonoBold(10.5))92                .tracking(0.6)93                .foregroundStyle(Color.vpInk)94                .padding(.horizontal, 14)95                .padding(.vertical, 8)96                .background(Capsule().fill(Color.vpSurface))97                .overlay(Capsule().stroke(Color.vpInk, lineWidth: 1.5))98            Spacer()99        }100        .padding(.top, 12)101    }102103    // MARK: - Fiche de sélection104105    private func selectionCard(_ u: NearbyUnit) -> some View {106        VStack(alignment: .leading, spacing: 10) {107            HStack(alignment: .firstTextBaseline) {108                VStack(alignment: .leading, spacing: 3) {109                    Text(fullAddress(u))110                        .font(.vpDisplay(17))111                        .foregroundStyle(Color.vpInk)112                        .textCase(.uppercase)113                    Text("\(u.municipalite ?? "—") · \(PropType.label(for: u.typeProp))")114                        .font(.vpBody(13))115                        .foregroundStyle(Color.vpInk2)116                }117                Spacer()118                Button {119                    withAnimation(.snappy(duration: 0.2)) { selected = nil }120                } label: {121                    Image(systemName: "xmark")122                        .font(.system(size: 12, weight: .bold))123                        .foregroundStyle(Color.vpInk)124                        .padding(9)125                        .background(Circle().stroke(Color.vpInk, lineWidth: 1.5))126                }127                .buttonStyle(.plain)128            }129            HStack(alignment: .lastTextBaseline) {130                VStack(alignment: .leading, spacing: 2) {131                    Text("ESTIMATION 2026")132                        .font(.vpMono(9))133                        .tracking(1)134                        .foregroundStyle(Color.vpInk3)135                    Text(Fmt.cad(u.est2026))136                        .font(.vpDisplay(26))137                        .foregroundStyle(Color.vpInk)138                }139                Spacer()140                NavigationLink(value: u.asSearchResult) {141                    Text("Estimer en détail →")142                        .font(.vpDisplay(14))143                        .foregroundStyle(Color.vpLime)144                        .padding(.horizontal, 16)145                        .padding(.vertical, 11)146                        .background(Capsule().fill(Color.vpInk))147                }148                .buttonStyle(.plain)149            }150        }151        .padding(16)152        .vpCard()153        .padding(.horizontal, 14)154        .padding(.bottom, 14)155    }156157    private func fullAddress(_ u: NearbyUnit) -> String {158        var s = u.adresse ?? "Adresse inconnue"159        if let apt = u.apt, !apt.isEmpty { s += ", app. \(apt)" }160        return s161    }162163    // MARK: - Chargement164165    private func reload(region: MKCoordinateRegion) {166        guard region.span.latitudeDelta <= Self.maxSpanLat else {167            zoomedOut = true168            units = []169            return170        }171        zoomedOut = false172        fetchTask?.cancel()173        fetchTask = Task {174            do {175                let found = try await VraiPrixAPI.shared.nearby(176                    lat: region.center.latitude,177                    lng: region.center.longitude,178                    halfLat: region.span.latitudeDelta / 2,179                    halfLng: region.span.longitudeDelta / 2180                )181                if !Task.isCancelled { units = found }182            } catch {183                /* requête annulée ou réseau — silencieux */184            }185        }186    }187188    private func locate() {189        locationManager.requestWhenInUseAuthorization()190        withAnimation {191            position = .userLocation(fallback: .region(Self.montreal))192        }193    }194}195196#Preview {197    MapSearchView()198}199