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%
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