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// 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