SPB Git

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%
6.2 KB · 158 lines swift
Raw Blame History
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