// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ListingDetailView.swift : fiche complète — galerie, faits, description // structurée (digest), commodités, carte, quartier, lien vers la source // ----------------------------------------------------------------------------- import SwiftUI import MapKit struct ListingDetailView: View { @Environment(AppModel.self) private var model /// annonce venue de la liste (partielle) — remplacée par la fiche complète let preview: Listing @State private var full: Listing? private var listing: Listing { full ?? preview } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { gallery header factsGrid inclusionsSection amenitiesSection descriptionSection mapSection poiSection quartierSection sourceFooter } .padding(.horizontal, 16) .padding(.bottom, 90) } .background(LK.paper) .navigationTitle(listing.unitType.isEmpty ? "Fiche" : listing.unitType) .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { ctaBar } .task { full = try? await API.listing(uid: preview.uid) } } // MARK: galerie private var gallery: some View { Group { if listing.images.isEmpty { ZStack { LK.limeSoft Image(systemName: "house.lodge") .font(.system(size: 44)) .foregroundStyle(LK.green.opacity(0.5)) } .frame(height: 250) } else { TabView { ForEach(listing.images, id: \.self) { src in AsyncImage(url: URL(string: src)) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) case .failure: ZStack { LK.surface2 Image(systemName: "photo").foregroundStyle(LK.ink3) } default: LK.surface2 } } .frame(height: 250) .clipped() } } .tabViewStyle(.page) .indexViewStyle(.page(backgroundDisplayMode: .always)) .frame(height: 250) } } .lkCard(radius: 12) .padding(.trailing, 5) .padding(.top, 8) } // MARK: en-tête private var header: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(Fmt.price(listing.price, label: listing.priceLabel)) .font(LKFont.display(30, .bold)) .kerning(-0.5) if listing.price != nil { Text(listing.details?.priceFrom == true ? "à partir de / mois" : "/ mois") .font(.system(size: 14)) .foregroundStyle(LK.ink3) } Spacer() if !listing.unitType.isEmpty { UnitTypeBadge(type: listing.unitType) } } priceDropNote Text(listing.title.isEmpty ? listing.address : listing.title) .font(LKFont.display(19, .medium)) HStack(spacing: 5) { Image(systemName: "mappin.and.ellipse").font(.system(size: 12)) Text( [listing.address, listing.sector, listing.city] .filter { !$0.isEmpty } .removingDuplicates() .joined(separator: " · ") ) } .font(.system(size: 14)) .foregroundStyle(LK.ink2) } } @ViewBuilder private var priceDropNote: some View { if let hist = listing.priceHistory, let first = hist.first?.price, let last = hist.last?.price, last < first { HStack(spacing: 6) { Image(systemName: "arrow.down.right") Text("Baisse de prix : \(Fmt.price(first)) → \(Fmt.price(last))") } .font(.system(size: 13, weight: .semibold)) .foregroundStyle(LK.green) .padding(.horizontal, 10) .padding(.vertical, 6) .background(LK.limeSoft) .clipShape(RoundedRectangle(cornerRadius: 6)) } } // MARK: faits private struct Fact: Identifiable { let id = UUID() let icon: String let label: String let value: String } private var facts: [Fact] { var out: [Fact] = [] if let dispo = Fmt.availability(listing.availabilityDate) { out.append(Fact(icon: "calendar", label: "Disponible", value: dispo)) } else if !listing.availability.isEmpty { out.append(Fact(icon: "calendar", label: "Disponibilité", value: listing.availability)) } if let area = listing.areaSqft { out.append(Fact(icon: "ruler", label: "Superficie", value: "\(Int(area)) pi²")) } if let pets = listing.pets { out.append(Fact(icon: "pawprint", label: "Animaux", value: pets.capitalized)) } if let furnished = listing.furnished { out.append(Fact(icon: "sofa", label: "Meublé", value: furnished ? "Oui" : "Non")) } if let floor = listing.details?.floor { out.append(Fact(icon: "building", label: "Étage", value: "\(floor)")) } if let parking = listing.details?.parking, parking.available == true { var v = "Oui" if parking.included == true { v = "Inclus" } else if let p = parking.price { v = Fmt.price(p) } out.append(Fact(icon: "car", label: "Stationnement", value: v)) } return out } @ViewBuilder private var factsGrid: some View { if !facts.isEmpty { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) { ForEach(facts) { f in HStack(spacing: 9) { Image(systemName: f.icon) .font(.system(size: 15)) .foregroundStyle(LK.green) .frame(width: 22) VStack(alignment: .leading, spacing: 1) { Text(f.label.uppercased()) .font(LKFont.mono(9, .medium)) .kerning(0.5) .foregroundStyle(LK.ink3) Text(f.value) .font(.system(size: 13.5, weight: .semibold)) .lineLimit(2) .minimumScaleFactor(0.8) } Spacer(minLength: 0) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(LK.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1)) } } } } // MARK: inclusions & commodités private var inclusionItems: [String] { var items: [String] = [] for (key, on) in listing.details?.inclusions ?? [:] where on { items.append(key.replacingOccurrences(of: "_", with: " ").capitalized) } let flags: [(Bool?, String)] = [ (listing.details?.ac, "Climatisation"), (listing.details?.elevator, "Ascenseur"), (listing.details?.balcony, "Balcon"), (listing.details?.pool, "Piscine"), (listing.details?.gym, "Gym"), (listing.details?.laundry, "Buanderie"), (listing.details?.storage, "Rangement"), ] for (on, label) in flags where on == true { items.append(label) } for (key, on) in listing.details?.appliances ?? [:] where on { items.append(key.replacingOccurrences(of: "_", with: " ").capitalized) } return items.removingDuplicates().sorted() } @ViewBuilder private var inclusionsSection: some View { if !inclusionItems.isEmpty { VStack(alignment: .leading, spacing: 10) { Kicker(text: "Inclus") chipsGrid(inclusionItems, checkmark: true) } } } @ViewBuilder private var amenitiesSection: some View { if !listing.amenities.isEmpty { VStack(alignment: .leading, spacing: 10) { Kicker(text: "Commodités") chipsGrid(Array(listing.amenities.prefix(18)), checkmark: false) } } } private func chipsGrid(_ items: [String], checkmark: Bool) -> some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 8)], alignment: .leading, spacing: 8) { ForEach(items, id: \.self) { item in HStack(spacing: 6) { if checkmark { Image(systemName: "checkmark") .font(.system(size: 10, weight: .bold)) .foregroundStyle(LK.green) } Text(item) .font(.system(size: 12.5, weight: .medium)) .lineLimit(1) .minimumScaleFactor(0.75) Spacer(minLength: 0) } .padding(.horizontal, 10) .padding(.vertical, 7) .background(checkmark ? LK.limeSoft : LK.surface) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(LK.line, lineWidth: 1)) } } } // MARK: description (digest structuré si présent) @ViewBuilder private var descriptionSection: some View { let digest = listing.digest let raw = listing.descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) if digest != nil || !raw.isEmpty { VStack(alignment: .leading, spacing: 12) { Kicker(text: "Description") if let bref = digest?.enBref, !bref.isEmpty { Text(bref) .font(.system(size: 14.5, weight: .medium)) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(LK.limeSoft) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.green.opacity(0.35), lineWidth: 1)) } if let sections = digest?.sections, !sections.isEmpty { ForEach(sections, id: \.self) { s in VStack(alignment: .leading, spacing: 4) { Text(s.titre) .font(.system(size: 14, weight: .bold)) Text(s.texte) .font(.system(size: 14)) .foregroundStyle(LK.ink2) } } } else if let clean = digest?.texteNettoye, !clean.isEmpty { Text(clean).font(.system(size: 14)).foregroundStyle(LK.ink2) } else if !raw.isEmpty { Text(raw).font(.system(size: 14)).foregroundStyle(LK.ink2) } } } } // MARK: carte & environs @ViewBuilder private var mapSection: some View { if let lat = listing.lat, let lng = listing.lng { let coord = CLLocationCoordinate2D(latitude: lat, longitude: lng) VStack(alignment: .leading, spacing: 10) { Kicker(text: "Emplacement") Map(initialPosition: .region(MKCoordinateRegion( center: coord, span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012) ))) { Marker(listing.title.isEmpty ? "Logement" : listing.title, coordinate: coord) .tint(LK.green) } .frame(height: 190) .allowsHitTesting(false) .lkCard(radius: 10) .padding(.trailing, 5) } } } private static let poiIcons: [String: String] = [ "epicerie": "cart", "pharmacie": "cross.case", "ecole": "graduationcap", "parc": "tree", "bus": "bus", "metro": "tram", "cegep": "book", "universite": "building.columns", "hopital": "cross", "sante": "stethoscope", "bibliotheque": "books.vertical", "garderie": "figure.and.child.holdinghands", ] @ViewBuilder private var poiSection: some View { if let poi = listing.poi, !poi.isEmpty { VStack(alignment: .leading, spacing: 10) { Kicker(text: "À proximité") VStack(spacing: 0) { ForEach(Array(poi.prefix(8).enumerated()), id: \.offset) { i, p in HStack(spacing: 10) { Image(systemName: Self.poiIcons[p.cat] ?? "mappin") .font(.system(size: 13)) .foregroundStyle(LK.green) .frame(width: 22) Text(p.name) .font(.system(size: 13.5, weight: .medium)) .lineLimit(1) Spacer() Text(Fmt.dist(p.distM)) .font(LKFont.mono(11, .medium)) .foregroundStyle(LK.ink3) } .padding(.horizontal, 13) .padding(.vertical, 9) if i < min(poi.count, 8) - 1 { Divider().padding(.leading, 45) } } } .background(LK.surface) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(LK.line, lineWidth: 1)) } } } @ViewBuilder private var quartierSection: some View { if let d = listing.quartier?.demographie { let rows: [(String, String)] = [ d.loyerMoyen.map { ("Loyer moyen du quartier", Fmt.price($0.rounded())) }, d.revenuMedian.map { ("Revenu médian", Fmt.price($0.rounded())) }, d.pctLocataires.map { ("Locataires", "\(Int(($0 * 100).rounded())) %") }, d.ageMedian.map { ("Âge médian", "\(Int($0.rounded())) ans") }, d.population.map { ("Population (aire)", Fmt.int(Int($0))) }, ].compactMap { $0 } if !rows.isEmpty { VStack(alignment: .leading, spacing: 10) { Kicker(text: "Le quartier en chiffres") LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) { ForEach(rows, id: \.0) { label, value in VStack(alignment: .leading, spacing: 2) { Text(value) .font(LKFont.display(17, .bold)) Text(label.uppercased()) .font(LKFont.mono(8.5, .medium)) .kerning(0.4) .foregroundStyle(LK.ink3) .lineLimit(2) } .padding(11) .frame(maxWidth: .infinity, alignment: .leading) .background(LK.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1)) } } } } } } // MARK: source private var sourceFooter: some View { HStack(spacing: 6) { Image(systemName: "checkmark.seal") .font(.system(size: 12)) Text("Source : \(model.sourceName(listing.source))") if let seen = Fmt.relative(listing.lastSeen) { Text("· vérifiée \(seen)") } } .font(LKFont.mono(11, .medium)) .foregroundStyle(LK.ink3) .padding(.top, 4) } private var ctaBar: some View { Group { if let url = URL(string: listing.url) { Link(destination: url) { HStack(spacing: 8) { Text("Voir l'annonce originale") .font(LKFont.display(17, .bold)) Image(systemName: "arrow.up.right") .font(.system(size: 14, weight: .bold)) } .frame(maxWidth: .infinity) .padding(.vertical, 15) .background(LK.ink) .foregroundStyle(LK.lime) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(LK.ink, lineWidth: 1.5)) .background(RoundedRectangle(cornerRadius: 10).fill(LK.green).offset(x: 4, y: 4)) } .padding(.horizontal, 16) .padding(.trailing, 4) .padding(.top, 8) .padding(.bottom, 4) .background(.ultraThinMaterial) } } } } private extension Array where Element: Hashable { func removingDuplicates() -> [Element] { var seen = Set() return filter { seen.insert($0).inserted } } }