// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // MapTabView.swift : carte province — pastilles d'annonces, mini-carte au tap // ----------------------------------------------------------------------------- import SwiftUI import MapKit private struct CityPreset: Identifiable { let name: String let center: CLLocationCoordinate2D let span: Double var id: String { name } } private let CITY_PRESETS: [CityPreset] = [ CityPreset(name: "Québec", center: .init(latitude: 46.8139, longitude: -71.2379), span: 0.30), CityPreset(name: "Lévis", center: .init(latitude: 46.7382, longitude: -71.2465), span: 0.25), CityPreset(name: "Montréal", center: .init(latitude: 45.5231, longitude: -73.5817), span: 0.30), CityPreset(name: "Gatineau", center: .init(latitude: 45.4765, longitude: -75.7013), span: 0.25), CityPreset(name: "Sherbrooke", center: .init(latitude: 45.4042, longitude: -71.8929), span: 0.25), CityPreset(name: "Trois-Rivières", center: .init(latitude: 46.3432, longitude: -72.5430), span: 0.25), ] struct MapTabView: View { @State private var listings: [Listing] = [] @State private var selected: Listing? @State private var detailListing: Listing? @State private var city = "Québec" @State private var position: MapCameraPosition = .region(MKCoordinateRegion( center: CITY_PRESETS[0].center, span: MKCoordinateSpan(latitudeDelta: 0.30, longitudeDelta: 0.30) )) private var located: [Listing] { listings.filter { $0.lat != nil && $0.lng != nil } } var body: some View { ZStack(alignment: .top) { Map(position: $position) { ForEach(located) { l in let isSelected = selected?.uid == l.uid let size: CGFloat = isSelected ? 16 : 12 Annotation("", coordinate: CLLocationCoordinate2D(latitude: l.lat!, longitude: l.lng!)) { Circle() .fill(isSelected ? LK.ink : LK.lime) .overlay(Circle().stroke(LK.ink, lineWidth: 1.5)) .frame(width: size, height: size) .onTapGesture { selected = l } } .annotationTitles(.hidden) } } .ignoresSafeArea(edges: .top) cityChips } .overlay(alignment: .bottom) { selectedCard } .sheet(item: $detailListing) { l in NavigationStack { ListingDetailView(preview: l) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Fermer") { detailListing = nil } } } } } .task(id: city) { await load() } } private func load() async { var f = ListingFilters() f.city = city selected = nil if let r = try? await API.listings(f) { listings = r.listings } if let preset = CITY_PRESETS.first(where: { $0.name == city }) { withAnimation { position = .region(MKCoordinateRegion( center: preset.center, span: MKCoordinateSpan(latitudeDelta: preset.span, longitudeDelta: preset.span) )) } } } private var cityChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(CITY_PRESETS) { p in Chip(label: p.name, selected: city == p.name) { city = p.name } .shadow(color: .black.opacity(0.12), radius: 3, y: 1) } } .padding(.horizontal, 16) .padding(.vertical, 10) } } @ViewBuilder private var selectedCard: some View { if let l = selected { Button { detailListing = l } label: { HStack(spacing: 12) { Group { if let src = l.images.first, let url = URL(string: src) { AsyncImage(url: url) { phase in if case .success(let image) = phase { image.resizable().aspectRatio(contentMode: .fill) } else { LK.limeSoft } } } else { LK.limeSoft } } .frame(width: 74, height: 74) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1)) VStack(alignment: .leading, spacing: 3) { Text(Fmt.price(l.price, label: l.priceLabel)) .font(LKFont.display(18, .bold)) Text(l.title.isEmpty ? l.address : l.title) .font(.system(size: 13.5, weight: .semibold)) .lineLimit(1) HStack(spacing: 6) { if !l.unitType.isEmpty { UnitTypeBadge(type: l.unitType) } Text([l.sector, l.city].filter { !$0.isEmpty }.joined(separator: " · ")) .font(.system(size: 12)) .foregroundStyle(LK.ink2) .lineLimit(1) } } Spacer() Image(systemName: "chevron.right") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(LK.ink3) } .padding(12) } .buttonStyle(.plain) .lkCard() .padding(.horizontal, 16) .padding(.bottom, 14) .transition(.move(edge: .bottom).combined(with: .opacity)) } } }