spb/lou-ka-ios Public
Lou·Ka iOS — app SwiftUI native de l'agrégateur de logements du Québec : filtres avancés, carte, stats, et mode Découverte (swipe) avec recommandation on-device
Swift 100%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// MapTabView.swift : carte province — pastilles d'annonces, mini-carte au tap5// -----------------------------------------------------------------------------6import SwiftUI7import MapKit89private struct CityPreset: Identifiable {10 let name: String11 let center: CLLocationCoordinate2D12 let span: Double13 var id: String { name }14}1516private let CITY_PRESETS: [CityPreset] = [17 CityPreset(name: "Québec", center: .init(latitude: 46.8139, longitude: -71.2379), span: 0.30),18 CityPreset(name: "Lévis", center: .init(latitude: 46.7382, longitude: -71.2465), span: 0.25),19 CityPreset(name: "Montréal", center: .init(latitude: 45.5231, longitude: -73.5817), span: 0.30),20 CityPreset(name: "Gatineau", center: .init(latitude: 45.4765, longitude: -75.7013), span: 0.25),21 CityPreset(name: "Sherbrooke", center: .init(latitude: 45.4042, longitude: -71.8929), span: 0.25),22 CityPreset(name: "Trois-Rivières", center: .init(latitude: 46.3432, longitude: -72.5430), span: 0.25),23]2425struct MapTabView: View {26 @State private var listings: [Listing] = []27 @State private var selected: Listing?28 @State private var detailListing: Listing?29 @State private var city = "Québec"30 @State private var position: MapCameraPosition = .region(MKCoordinateRegion(31 center: CITY_PRESETS[0].center,32 span: MKCoordinateSpan(latitudeDelta: 0.30, longitudeDelta: 0.30)33 ))3435 private var located: [Listing] {36 listings.filter { $0.lat != nil && $0.lng != nil }37 }3839 var body: some View {40 ZStack(alignment: .top) {41 Map(position: $position) {42 ForEach(located) { l in43 let isSelected = selected?.uid == l.uid44 let size: CGFloat = isSelected ? 16 : 1245 Annotation("", coordinate: CLLocationCoordinate2D(latitude: l.lat!, longitude: l.lng!)) {46 Circle()47 .fill(isSelected ? LK.ink : LK.lime)48 .overlay(Circle().stroke(LK.ink, lineWidth: 1.5))49 .frame(width: size, height: size)50 .onTapGesture { selected = l }51 }52 .annotationTitles(.hidden)53 }54 }55 .ignoresSafeArea(edges: .top)5657 cityChips58 }59 .overlay(alignment: .bottom) { selectedCard }60 .sheet(item: $detailListing) { l in61 NavigationStack {62 ListingDetailView(preview: l)63 .toolbar {64 ToolbarItem(placement: .topBarTrailing) {65 Button("Fermer") { detailListing = nil }66 }67 }68 }69 }70 .task(id: city) { await load() }71 }7273 private func load() async {74 var f = ListingFilters()75 f.city = city76 selected = nil77 if let r = try? await API.listings(f) {78 listings = r.listings79 }80 if let preset = CITY_PRESETS.first(where: { $0.name == city }) {81 withAnimation {82 position = .region(MKCoordinateRegion(83 center: preset.center,84 span: MKCoordinateSpan(latitudeDelta: preset.span, longitudeDelta: preset.span)85 ))86 }87 }88 }8990 private var cityChips: some View {91 ScrollView(.horizontal, showsIndicators: false) {92 HStack(spacing: 8) {93 ForEach(CITY_PRESETS) { p in94 Chip(label: p.name, selected: city == p.name) {95 city = p.name96 }97 .shadow(color: .black.opacity(0.12), radius: 3, y: 1)98 }99 }100 .padding(.horizontal, 16)101 .padding(.vertical, 10)102 }103 }104105 @ViewBuilder106 private var selectedCard: some View {107 if let l = selected {108 Button {109 detailListing = l110 } label: {111 HStack(spacing: 12) {112 Group {113 if let src = l.images.first, let url = URL(string: src) {114 AsyncImage(url: url) { phase in115 if case .success(let image) = phase {116 image.resizable().aspectRatio(contentMode: .fill)117 } else {118 LK.limeSoft119 }120 }121 } else {122 LK.limeSoft123 }124 }125 .frame(width: 74, height: 74)126 .clipShape(RoundedRectangle(cornerRadius: 8))127 .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1))128129 VStack(alignment: .leading, spacing: 3) {130 Text(Fmt.price(l.price, label: l.priceLabel))131 .font(LKFont.display(18, .bold))132 Text(l.title.isEmpty ? l.address : l.title)133 .font(.system(size: 13.5, weight: .semibold))134 .lineLimit(1)135 HStack(spacing: 6) {136 if !l.unitType.isEmpty { UnitTypeBadge(type: l.unitType) }137 Text([l.sector, l.city].filter { !$0.isEmpty }.joined(separator: " · "))138 .font(.system(size: 12))139 .foregroundStyle(LK.ink2)140 .lineLimit(1)141 }142 }143 Spacer()144 Image(systemName: "chevron.right")145 .font(.system(size: 13, weight: .semibold))146 .foregroundStyle(LK.ink3)147 }148 .padding(12)149 }150 .buttonStyle(.plain)151 .lkCard()152 .padding(.horizontal, 16)153 .padding(.bottom, 14)154 .transition(.move(edge: .bottom).combined(with: .opacity))155 }156 }157}158