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// ListingCardView.swift : carte d'annonce (photo, prix, adresse, badges)5// -----------------------------------------------------------------------------6import SwiftUI78struct ListingCardView: View {9 @Environment(AppModel.self) private var model10 let listing: Listing1112 var body: some View {13 VStack(alignment: .leading, spacing: 0) {14 photo15 VStack(alignment: .leading, spacing: 8) {16 HStack(alignment: .firstTextBaseline, spacing: 6) {17 Text(Fmt.price(listing.price, label: listing.priceLabel))18 .font(LKFont.display(23, .bold))19 .kerning(-0.5)20 .foregroundStyle(LK.ink)21 if listing.price != nil {22 Text(listing.details?.priceFrom == true ? "à partir de / mois" : "/ mois")23 .font(LKFont.mono(10.5))24 .foregroundStyle(LK.ink3)25 }26 Spacer()27 }28 Text(listing.title.isEmpty ? listing.address : listing.title)29 .font(.system(size: 15.5, weight: .semibold))30 .foregroundStyle(LK.ink)31 .lineLimit(2)32 HStack(spacing: 5) {33 Image(systemName: "mappin")34 .font(.system(size: 11))35 Text([listing.sector, listing.city].filter { !$0.isEmpty }.joined(separator: " · "))36 .lineLimit(1)37 }38 .font(.system(size: 13))39 .foregroundStyle(LK.ink2)4041 Rectangle().fill(LK.line).frame(height: 1).padding(.vertical, 2)4243 HStack(spacing: 8) {44 if let dispo = Fmt.availability(listing.availabilityDate) {45 metaPill(icon: "calendar", text: dispo, highlight: listing.availabilityDate == "now")46 }47 if let area = listing.areaSqft {48 metaPill(icon: "ruler", text: "\(Int(area)) pi²", highlight: false)49 }50 Spacer()51 Text(model.sourceName(listing.source).uppercased())52 .font(LKFont.mono(9, .medium))53 .kerning(0.5)54 .foregroundStyle(LK.ink3)55 .lineLimit(1)56 }57 }58 .padding(14)59 }60 .lkCard(offset: 6)61 }6263 private func metaPill(icon: String, text: String, highlight: Bool) -> some View {64 HStack(spacing: 4) {65 Image(systemName: icon).font(.system(size: 9.5, weight: .semibold))66 Text(text)67 .font(LKFont.mono(10.5, .medium))68 }69 .padding(.horizontal, 8)70 .padding(.vertical, 4)71 .background(highlight ? LK.limeSoft : LK.surface2)72 .foregroundStyle(highlight ? LK.greenDeep : LK.ink2)73 .clipShape(Capsule())74 .overlay(Capsule().stroke(highlight ? LK.green.opacity(0.4) : LK.line, lineWidth: 1))75 }7677 private var photo: some View {78 ZStack(alignment: .topLeading) {79 Group {80 if let first = listing.images.first, let url = URL(string: first) {81 AsyncImage(url: url) { phase in82 switch phase {83 case .success(let image):84 image.resizable().aspectRatio(contentMode: .fill)85 case .failure:86 placeholder87 default:88 LK.surface289 }90 }91 } else {92 placeholder93 }94 }95 .frame(height: 190)96 .frame(maxWidth: .infinity)97 .clipped()9899 HStack(spacing: 6) {100 if !listing.unitType.isEmpty {101 UnitTypeBadge(type: listing.unitType)102 }103 if listing.images.count > 1 {104 HStack(spacing: 3) {105 Image(systemName: "photo.stack").font(.system(size: 10))106 Text("\(listing.images.count)")107 .font(LKFont.mono(10.5, .medium))108 }109 .padding(.horizontal, 7)110 .padding(.vertical, 4)111 .background(.black.opacity(0.55))112 .foregroundStyle(.white)113 .clipShape(RoundedRectangle(cornerRadius: 5))114 }115 }116 .padding(10)117 }118 .background(LK.surface2)119 .overlay(Rectangle().fill(LK.ink).frame(height: 1.8), alignment: .bottom)120 }121122 private var placeholder: some View {123 ZStack {124 LK.limeSoft125 Image(systemName: "house.lodge")126 .font(.system(size: 36))127 .foregroundStyle(LK.green.opacity(0.5))128 }129 }130}131