// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Recherche par carte : propriétés avoisinantes (MapKit), géolocalisation, // fiche d'aperçu → estimation complète. Même API /api/nearby que le site. import SwiftUI import MapKit import CoreLocation struct MapSearchView: View { private static let montreal = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 45.5421, longitude: -73.6646), span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.016) ) /// Au-delà de cette étendue, on ne charge pas (trop dézoomé). private static let maxSpanLat = 0.045 @State private var position: MapCameraPosition = .region(Self.montreal) @State private var units: [NearbyUnit] = [] @State private var selected: NearbyUnit? @State private var zoomedOut = false @State private var fetchTask: Task? @State private var locationManager = CLLocationManager() var body: some View { NavigationStack { ZStack(alignment: .bottom) { map overlayChip if let selected { selectionCard(selected) .transition(.move(edge: .bottom).combined(with: .opacity)) } } .toolbar { ToolbarItem(placement: .principal) { Wordmark(size: 20) } ToolbarItem(placement: .topBarTrailing) { Button { locate() } label: { Image(systemName: "location.fill") .font(.system(size: 14, weight: .bold)) .foregroundStyle(Color.vpLime) .padding(8) .background(Circle().fill(Color.vpInk)) } } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(Color.vpPaper, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .navigationDestination(for: SearchResult.self) { r in EstimateLoaderView(searchResult: r) } } } // MARK: - Carte private var map: some View { Map(position: $position) { UserAnnotation() ForEach(units) { u in Annotation("", coordinate: CLLocationCoordinate2D(latitude: u.lat, longitude: u.lng)) { Button { withAnimation(.snappy(duration: 0.2)) { selected = u } } label: { Circle() .fill(selected?.id == u.id ? Color.vpLime : Color.vpInk) .frame(width: 15, height: 15) .overlay(Circle().stroke(selected?.id == u.id ? Color.vpInk : Color.vpLime, lineWidth: 2.5)) .shadow(color: Color.vpInk.opacity(0.35), radius: 2, y: 1) } .buttonStyle(.plain) .accessibilityIdentifier("vp-dot") .accessibilityLabel(u.adresse ?? "Propriété") } } } .mapStyle(.standard(pointsOfInterest: .excludingAll)) .onMapCameraChange(frequency: .onEnd) { context in reload(region: context.region) } .task { reload(region: Self.montreal) } // chargement initial .ignoresSafeArea(edges: .bottom) } private var overlayChip: some View { VStack { Text(zoomedOut ? "Zoomez pour voir les propriétés" : "\(units.count) propriétés · touchez un point") .font(.vpMonoBold(10.5)) .tracking(0.6) .foregroundStyle(Color.vpInk) .padding(.horizontal, 14) .padding(.vertical, 8) .background(Capsule().fill(Color.vpSurface)) .overlay(Capsule().stroke(Color.vpInk, lineWidth: 1.5)) Spacer() } .padding(.top, 12) } // MARK: - Fiche de sélection private func selectionCard(_ u: NearbyUnit) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 3) { Text(fullAddress(u)) .font(.vpDisplay(17)) .foregroundStyle(Color.vpInk) .textCase(.uppercase) Text("\(u.municipalite ?? "—") · \(PropType.label(for: u.typeProp))") .font(.vpBody(13)) .foregroundStyle(Color.vpInk2) } Spacer() Button { withAnimation(.snappy(duration: 0.2)) { selected = nil } } label: { Image(systemName: "xmark") .font(.system(size: 12, weight: .bold)) .foregroundStyle(Color.vpInk) .padding(9) .background(Circle().stroke(Color.vpInk, lineWidth: 1.5)) } .buttonStyle(.plain) } HStack(alignment: .lastTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text("ESTIMATION 2026") .font(.vpMono(9)) .tracking(1) .foregroundStyle(Color.vpInk3) Text(Fmt.cad(u.est2026)) .font(.vpDisplay(26)) .foregroundStyle(Color.vpInk) } Spacer() NavigationLink(value: u.asSearchResult) { Text("Estimer en détail →") .font(.vpDisplay(14)) .foregroundStyle(Color.vpLime) .padding(.horizontal, 16) .padding(.vertical, 11) .background(Capsule().fill(Color.vpInk)) } .buttonStyle(.plain) } } .padding(16) .vpCard() .padding(.horizontal, 14) .padding(.bottom, 14) } private func fullAddress(_ u: NearbyUnit) -> String { var s = u.adresse ?? "Adresse inconnue" if let apt = u.apt, !apt.isEmpty { s += ", app. \(apt)" } return s } // MARK: - Chargement private func reload(region: MKCoordinateRegion) { guard region.span.latitudeDelta <= Self.maxSpanLat else { zoomedOut = true units = [] return } zoomedOut = false fetchTask?.cancel() fetchTask = Task { do { let found = try await VraiPrixAPI.shared.nearby( lat: region.center.latitude, lng: region.center.longitude, halfLat: region.span.latitudeDelta / 2, halfLng: region.span.longitudeDelta / 2 ) if !Task.isCancelled { units = found } } catch { /* requête annulée ou réseau — silencieux */ } } } private func locate() { locationManager.requestWhenInUseAuthorization() withAnimation { position = .userLocation(fallback: .region(Self.montreal)) } } } #Preview { MapSearchView() }