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%
18.4 KB · 462 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// ListingDetailView.swift : fiche complète — galerie, faits, description5//   structurée (digest), commodités, carte, quartier, lien vers la source6// -----------------------------------------------------------------------------7import SwiftUI8import MapKit910struct ListingDetailView: View {11    @Environment(AppModel.self) private var model12    /// annonce venue de la liste (partielle) — remplacée par la fiche complète13    let preview: Listing14    @State private var full: Listing?1516    private var listing: Listing { full ?? preview }1718    var body: some View {19        ScrollView {20            VStack(alignment: .leading, spacing: 20) {21                gallery22                header23                factsGrid24                inclusionsSection25                amenitiesSection26                descriptionSection27                mapSection28                poiSection29                quartierSection30                sourceFooter31            }32            .padding(.horizontal, 16)33            .padding(.bottom, 90)34        }35        .background(LK.paper)36        .navigationTitle(listing.unitType.isEmpty ? "Fiche" : listing.unitType)37        .navigationBarTitleDisplayMode(.inline)38        .safeAreaInset(edge: .bottom) { ctaBar }39        .task {40            full = try? await API.listing(uid: preview.uid)41        }42    }4344    // MARK: galerie4546    private var gallery: some View {47        Group {48            if listing.images.isEmpty {49                ZStack {50                    LK.limeSoft51                    Image(systemName: "house.lodge")52                        .font(.system(size: 44))53                        .foregroundStyle(LK.green.opacity(0.5))54                }55                .frame(height: 250)56            } else {57                TabView {58                    ForEach(listing.images, id: \.self) { src in59                        AsyncImage(url: URL(string: src)) { phase in60                            switch phase {61                            case .success(let image):62                                image.resizable().aspectRatio(contentMode: .fill)63                            case .failure:64                                ZStack {65                                    LK.surface266                                    Image(systemName: "photo").foregroundStyle(LK.ink3)67                                }68                            default:69                                LK.surface270                            }71                        }72                        .frame(height: 250)73                        .clipped()74                    }75                }76                .tabViewStyle(.page)77                .indexViewStyle(.page(backgroundDisplayMode: .always))78                .frame(height: 250)79            }80        }81        .lkCard(radius: 12)82        .padding(.trailing, 5)83        .padding(.top, 8)84    }8586    // MARK: en-tête8788    private var header: some View {89        VStack(alignment: .leading, spacing: 8) {90            HStack(alignment: .firstTextBaseline, spacing: 6) {91                Text(Fmt.price(listing.price, label: listing.priceLabel))92                    .font(LKFont.display(30, .bold))93                    .kerning(-0.5)94                if listing.price != nil {95                    Text(listing.details?.priceFrom == true ? "à partir de / mois" : "/ mois")96                        .font(.system(size: 14))97                        .foregroundStyle(LK.ink3)98                }99                Spacer()100                if !listing.unitType.isEmpty {101                    UnitTypeBadge(type: listing.unitType)102                }103            }104            priceDropNote105            Text(listing.title.isEmpty ? listing.address : listing.title)106                .font(LKFont.display(19, .medium))107            HStack(spacing: 5) {108                Image(systemName: "mappin.and.ellipse").font(.system(size: 12))109                Text(110                    [listing.address, listing.sector, listing.city]111                        .filter { !$0.isEmpty }112                        .removingDuplicates()113                        .joined(separator: " · ")114                )115            }116            .font(.system(size: 14))117            .foregroundStyle(LK.ink2)118        }119    }120121    @ViewBuilder122    private var priceDropNote: some View {123        if let hist = listing.priceHistory,124           let first = hist.first?.price, let last = hist.last?.price,125           last < first {126            HStack(spacing: 6) {127                Image(systemName: "arrow.down.right")128                Text("Baisse de prix : \(Fmt.price(first))\(Fmt.price(last))")129            }130            .font(.system(size: 13, weight: .semibold))131            .foregroundStyle(LK.green)132            .padding(.horizontal, 10)133            .padding(.vertical, 6)134            .background(LK.limeSoft)135            .clipShape(RoundedRectangle(cornerRadius: 6))136        }137    }138139    // MARK: faits140141    private struct Fact: Identifiable {142        let id = UUID()143        let icon: String144        let label: String145        let value: String146    }147148    private var facts: [Fact] {149        var out: [Fact] = []150        if let dispo = Fmt.availability(listing.availabilityDate) {151            out.append(Fact(icon: "calendar", label: "Disponible", value: dispo))152        } else if !listing.availability.isEmpty {153            out.append(Fact(icon: "calendar", label: "Disponibilité", value: listing.availability))154        }155        if let area = listing.areaSqft {156            out.append(Fact(icon: "ruler", label: "Superficie", value: "\(Int(area)) pi²"))157        }158        if let pets = listing.pets {159            out.append(Fact(icon: "pawprint", label: "Animaux", value: pets.capitalized))160        }161        if let furnished = listing.furnished {162            out.append(Fact(icon: "sofa", label: "Meublé", value: furnished ? "Oui" : "Non"))163        }164        if let floor = listing.details?.floor {165            out.append(Fact(icon: "building", label: "Étage", value: "\(floor)"))166        }167        if let parking = listing.details?.parking, parking.available == true {168            var v = "Oui"169            if parking.included == true { v = "Inclus" }170            else if let p = parking.price { v = Fmt.price(p) }171            out.append(Fact(icon: "car", label: "Stationnement", value: v))172        }173        return out174    }175176    @ViewBuilder177    private var factsGrid: some View {178        if !facts.isEmpty {179            LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) {180                ForEach(facts) { f in181                    HStack(spacing: 9) {182                        Image(systemName: f.icon)183                            .font(.system(size: 15))184                            .foregroundStyle(LK.green)185                            .frame(width: 22)186                        VStack(alignment: .leading, spacing: 1) {187                            Text(f.label.uppercased())188                                .font(LKFont.mono(9, .medium))189                                .kerning(0.5)190                                .foregroundStyle(LK.ink3)191                            Text(f.value)192                                .font(.system(size: 13.5, weight: .semibold))193                                .lineLimit(2)194                                .minimumScaleFactor(0.8)195                        }196                        Spacer(minLength: 0)197                    }198                    .padding(10)199                    .frame(maxWidth: .infinity, alignment: .leading)200                    .background(LK.surface)201                    .clipShape(RoundedRectangle(cornerRadius: 8))202                    .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1))203                }204            }205        }206    }207208    // MARK: inclusions & commodités209210    private var inclusionItems: [String] {211        var items: [String] = []212        for (key, on) in listing.details?.inclusions ?? [:] where on {213            items.append(key.replacingOccurrences(of: "_", with: " ").capitalized)214        }215        let flags: [(Bool?, String)] = [216            (listing.details?.ac, "Climatisation"),217            (listing.details?.elevator, "Ascenseur"),218            (listing.details?.balcony, "Balcon"),219            (listing.details?.pool, "Piscine"),220            (listing.details?.gym, "Gym"),221            (listing.details?.laundry, "Buanderie"),222            (listing.details?.storage, "Rangement"),223        ]224        for (on, label) in flags where on == true { items.append(label) }225        for (key, on) in listing.details?.appliances ?? [:] where on {226            items.append(key.replacingOccurrences(of: "_", with: " ").capitalized)227        }228        return items.removingDuplicates().sorted()229    }230231    @ViewBuilder232    private var inclusionsSection: some View {233        if !inclusionItems.isEmpty {234            VStack(alignment: .leading, spacing: 10) {235                Kicker(text: "Inclus")236                chipsGrid(inclusionItems, checkmark: true)237            }238        }239    }240241    @ViewBuilder242    private var amenitiesSection: some View {243        if !listing.amenities.isEmpty {244            VStack(alignment: .leading, spacing: 10) {245                Kicker(text: "Commodités")246                chipsGrid(Array(listing.amenities.prefix(18)), checkmark: false)247            }248        }249    }250251    private func chipsGrid(_ items: [String], checkmark: Bool) -> some View {252        LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 8)], alignment: .leading, spacing: 8) {253            ForEach(items, id: \.self) { item in254                HStack(spacing: 6) {255                    if checkmark {256                        Image(systemName: "checkmark")257                            .font(.system(size: 10, weight: .bold))258                            .foregroundStyle(LK.green)259                    }260                    Text(item)261                        .font(.system(size: 12.5, weight: .medium))262                        .lineLimit(1)263                        .minimumScaleFactor(0.75)264                    Spacer(minLength: 0)265                }266                .padding(.horizontal, 10)267                .padding(.vertical, 7)268                .background(checkmark ? LK.limeSoft : LK.surface)269                .clipShape(RoundedRectangle(cornerRadius: 6))270                .overlay(RoundedRectangle(cornerRadius: 6).stroke(LK.line, lineWidth: 1))271            }272        }273    }274275    // MARK: description (digest structuré si présent)276277    @ViewBuilder278    private var descriptionSection: some View {279        let digest = listing.digest280        let raw = listing.descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)281        if digest != nil || !raw.isEmpty {282            VStack(alignment: .leading, spacing: 12) {283                Kicker(text: "Description")284                if let bref = digest?.enBref, !bref.isEmpty {285                    Text(bref)286                        .font(.system(size: 14.5, weight: .medium))287                        .padding(12)288                        .frame(maxWidth: .infinity, alignment: .leading)289                        .background(LK.limeSoft)290                        .clipShape(RoundedRectangle(cornerRadius: 8))291                        .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.green.opacity(0.35), lineWidth: 1))292                }293                if let sections = digest?.sections, !sections.isEmpty {294                    ForEach(sections, id: \.self) { s in295                        VStack(alignment: .leading, spacing: 4) {296                            Text(s.titre)297                                .font(.system(size: 14, weight: .bold))298                            Text(s.texte)299                                .font(.system(size: 14))300                                .foregroundStyle(LK.ink2)301                        }302                    }303                } else if let clean = digest?.texteNettoye, !clean.isEmpty {304                    Text(clean).font(.system(size: 14)).foregroundStyle(LK.ink2)305                } else if !raw.isEmpty {306                    Text(raw).font(.system(size: 14)).foregroundStyle(LK.ink2)307                }308            }309        }310    }311312    // MARK: carte & environs313314    @ViewBuilder315    private var mapSection: some View {316        if let lat = listing.lat, let lng = listing.lng {317            let coord = CLLocationCoordinate2D(latitude: lat, longitude: lng)318            VStack(alignment: .leading, spacing: 10) {319                Kicker(text: "Emplacement")320                Map(initialPosition: .region(MKCoordinateRegion(321                    center: coord,322                    span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012)323                ))) {324                    Marker(listing.title.isEmpty ? "Logement" : listing.title, coordinate: coord)325                        .tint(LK.green)326                }327                .frame(height: 190)328                .allowsHitTesting(false)329                .lkCard(radius: 10)330                .padding(.trailing, 5)331            }332        }333    }334335    private static let poiIcons: [String: String] = [336        "epicerie": "cart", "pharmacie": "cross.case", "ecole": "graduationcap",337        "parc": "tree", "bus": "bus", "metro": "tram", "cegep": "book",338        "universite": "building.columns", "hopital": "cross", "sante": "stethoscope",339        "bibliotheque": "books.vertical", "garderie": "figure.and.child.holdinghands",340    ]341342    @ViewBuilder343    private var poiSection: some View {344        if let poi = listing.poi, !poi.isEmpty {345            VStack(alignment: .leading, spacing: 10) {346                Kicker(text: "À proximité")347                VStack(spacing: 0) {348                    ForEach(Array(poi.prefix(8).enumerated()), id: \.offset) { i, p in349                        HStack(spacing: 10) {350                            Image(systemName: Self.poiIcons[p.cat] ?? "mappin")351                                .font(.system(size: 13))352                                .foregroundStyle(LK.green)353                                .frame(width: 22)354                            Text(p.name)355                                .font(.system(size: 13.5, weight: .medium))356                                .lineLimit(1)357                            Spacer()358                            Text(Fmt.dist(p.distM))359                                .font(LKFont.mono(11, .medium))360                                .foregroundStyle(LK.ink3)361                        }362                        .padding(.horizontal, 13)363                        .padding(.vertical, 9)364                        if i < min(poi.count, 8) - 1 {365                            Divider().padding(.leading, 45)366                        }367                    }368                }369                .background(LK.surface)370                .clipShape(RoundedRectangle(cornerRadius: 10))371                .overlay(RoundedRectangle(cornerRadius: 10).stroke(LK.line, lineWidth: 1))372            }373        }374    }375376    @ViewBuilder377    private var quartierSection: some View {378        if let d = listing.quartier?.demographie {379            let rows: [(String, String)] = [380                d.loyerMoyen.map { ("Loyer moyen du quartier", Fmt.price($0.rounded())) },381                d.revenuMedian.map { ("Revenu médian", Fmt.price($0.rounded())) },382                d.pctLocataires.map { ("Locataires", "\(Int(($0 * 100).rounded())) %") },383                d.ageMedian.map { ("Âge médian", "\(Int($0.rounded())) ans") },384                d.population.map { ("Population (aire)", Fmt.int(Int($0))) },385            ].compactMap { $0 }386            if !rows.isEmpty {387                VStack(alignment: .leading, spacing: 10) {388                    Kicker(text: "Le quartier en chiffres")389                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) {390                        ForEach(rows, id: \.0) { label, value in391                            VStack(alignment: .leading, spacing: 2) {392                                Text(value)393                                    .font(LKFont.display(17, .bold))394                                Text(label.uppercased())395                                    .font(LKFont.mono(8.5, .medium))396                                    .kerning(0.4)397                                    .foregroundStyle(LK.ink3)398                                    .lineLimit(2)399                            }400                            .padding(11)401                            .frame(maxWidth: .infinity, alignment: .leading)402                            .background(LK.surface)403                            .clipShape(RoundedRectangle(cornerRadius: 8))404                            .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1))405                        }406                    }407                }408            }409        }410    }411412    // MARK: source413414    private var sourceFooter: some View {415        HStack(spacing: 6) {416            Image(systemName: "checkmark.seal")417                .font(.system(size: 12))418            Text("Source : \(model.sourceName(listing.source))")419            if let seen = Fmt.relative(listing.lastSeen) {420                Text("· vérifiée \(seen)")421            }422        }423        .font(LKFont.mono(11, .medium))424        .foregroundStyle(LK.ink3)425        .padding(.top, 4)426    }427428    private var ctaBar: some View {429        Group {430            if let url = URL(string: listing.url) {431                Link(destination: url) {432                    HStack(spacing: 8) {433                        Text("Voir l'annonce originale")434                            .font(LKFont.display(17, .bold))435                        Image(systemName: "arrow.up.right")436                            .font(.system(size: 14, weight: .bold))437                    }438                    .frame(maxWidth: .infinity)439                    .padding(.vertical, 15)440                    .background(LK.ink)441                    .foregroundStyle(LK.lime)442                    .clipShape(RoundedRectangle(cornerRadius: 10))443                    .overlay(RoundedRectangle(cornerRadius: 10).stroke(LK.ink, lineWidth: 1.5))444                    .background(RoundedRectangle(cornerRadius: 10).fill(LK.green).offset(x: 4, y: 4))445                }446                .padding(.horizontal, 16)447                .padding(.trailing, 4)448                .padding(.top, 8)449                .padding(.bottom, 4)450                .background(.ultraThinMaterial)451            }452        }453    }454}455456private extension Array where Element: Hashable {457    func removingDuplicates() -> [Element] {458        var seen = Set<Element>()459        return filter { seen.insert($0).inserted }460    }461}462