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// Models.swift : modèles décodés depuis l'API (miroir de frontend/src/api.ts)5// Décodage volontairement tolérant : un champ inattendu chez un connecteur6// ne doit jamais faire disparaître l'annonce entière de l'app.7// -----------------------------------------------------------------------------8import Foundation910// MARK: - Annonce1112struct ListingsResponse: Decodable {13 let total: Int14 let listings: [Listing]15}1617struct Listing: Identifiable, Decodable, Hashable {18 let uid: String19 let source: String20 let url: String21 let title: String22 let address: String23 let sector: String24 let city: String25 let unitType: String26 let price: Double?27 let priceLabel: String28 let availability: String29 let availabilityDate: String?30 let areaSqft: Double?31 let pets: String?32 let furnished: Bool?33 let descriptionText: String34 let amenities: [String]35 let details: ListingDetails?36 let images: [String]37 let lat: Double?38 let lng: Double?39 // fiche complète seulement40 let poi: [Poi]?41 let quartier: Quartier?42 let digest: Digest?43 let priceHistory: [PricePoint]?44 let lastSeen: Double?4546 var id: String { uid }4748 static func == (lhs: Listing, rhs: Listing) -> Bool { lhs.uid == rhs.uid }49 func hash(into hasher: inout Hasher) { hasher.combine(uid) }5051 enum CodingKeys: String, CodingKey {52 case uid, source, url, title, address, sector, city, price, availability53 case pets, furnished, amenities, details, images, lat, lng, poi, quartier, digest54 case unitType = "unit_type"55 case priceLabel = "price_label"56 case availabilityDate = "availability_date"57 case areaSqft = "area_sqft"58 case descriptionText = "description"59 case priceHistory = "price_history"60 case lastSeen = "last_seen"61 }6263 init(from decoder: Decoder) throws {64 let c = try decoder.container(keyedBy: CodingKeys.self)65 uid = try c.decode(String.self, forKey: .uid)66 source = (try? c.decode(String.self, forKey: .source)) ?? ""67 url = (try? c.decode(String.self, forKey: .url)) ?? ""68 title = (try? c.decode(String.self, forKey: .title)) ?? ""69 address = (try? c.decode(String.self, forKey: .address)) ?? ""70 sector = (try? c.decode(String.self, forKey: .sector)) ?? ""71 city = (try? c.decode(String.self, forKey: .city)) ?? ""72 unitType = (try? c.decode(String.self, forKey: .unitType)) ?? ""73 price = (try? c.decodeIfPresent(Double.self, forKey: .price)) ?? nil74 priceLabel = (try? c.decode(String.self, forKey: .priceLabel)) ?? ""75 availability = (try? c.decode(String.self, forKey: .availability)) ?? ""76 availabilityDate = (try? c.decodeIfPresent(String.self, forKey: .availabilityDate)) ?? nil77 areaSqft = (try? c.decodeIfPresent(Double.self, forKey: .areaSqft)) ?? nil78 pets = (try? c.decodeIfPresent(String.self, forKey: .pets)) ?? nil79 furnished = (try? c.decodeIfPresent(Bool.self, forKey: .furnished)) ?? nil80 descriptionText = (try? c.decode(String.self, forKey: .descriptionText)) ?? ""81 amenities = (try? c.decode([String].self, forKey: .amenities)) ?? []82 details = (try? c.decodeIfPresent(ListingDetails.self, forKey: .details)) ?? nil83 images = (try? c.decode([String].self, forKey: .images)) ?? []84 lat = (try? c.decodeIfPresent(Double.self, forKey: .lat)) ?? nil85 lng = (try? c.decodeIfPresent(Double.self, forKey: .lng)) ?? nil86 poi = (try? c.decodeIfPresent([Poi].self, forKey: .poi)) ?? nil87 quartier = (try? c.decodeIfPresent(Quartier.self, forKey: .quartier)) ?? nil88 digest = (try? c.decodeIfPresent(Digest.self, forKey: .digest)) ?? nil89 priceHistory = (try? c.decodeIfPresent([PricePoint].self, forKey: .priceHistory)) ?? nil90 lastSeen = (try? c.decodeIfPresent(Double.self, forKey: .lastSeen)) ?? nil91 }92}9394struct PricePoint: Decodable, Hashable {95 let ts: Double96 let price: Double?97}9899struct ListingDetails: Decodable, Hashable {100 var inclusions: [String: Bool]?101 var appliances: [String: Bool]?102 var parking: Parking?103 var contact: Contact?104 var ac: Bool?105 var elevator: Bool?106 var balcony: Bool?107 var pool: Bool?108 var gym: Bool?109 var laundry: Bool?110 var storage: Bool?111 var floor: Int?112 var priceFrom: Bool?113114 struct Parking: Decodable, Hashable {115 var available: Bool?116 var type: String?117 var included: Bool?118 var price: Double?119 }120121 struct Contact: Decodable, Hashable {122 var phone: String?123 var email: String?124 }125126 enum CodingKeys: String, CodingKey {127 case inclusions, appliances, parking, contact128 case ac, elevator, balcony, pool, gym, laundry, storage, floor129 case priceFrom = "price_from"130 }131132 init(from decoder: Decoder) throws {133 let c = try decoder.container(keyedBy: CodingKeys.self)134 inclusions = (try? c.decodeIfPresent([String: Bool].self, forKey: .inclusions)) ?? nil135 appliances = (try? c.decodeIfPresent([String: Bool].self, forKey: .appliances)) ?? nil136 parking = (try? c.decodeIfPresent(Parking.self, forKey: .parking)) ?? nil137 contact = (try? c.decodeIfPresent(Contact.self, forKey: .contact)) ?? nil138 ac = (try? c.decodeIfPresent(Bool.self, forKey: .ac)) ?? nil139 elevator = (try? c.decodeIfPresent(Bool.self, forKey: .elevator)) ?? nil140 balcony = (try? c.decodeIfPresent(Bool.self, forKey: .balcony)) ?? nil141 pool = (try? c.decodeIfPresent(Bool.self, forKey: .pool)) ?? nil142 gym = (try? c.decodeIfPresent(Bool.self, forKey: .gym)) ?? nil143 laundry = (try? c.decodeIfPresent(Bool.self, forKey: .laundry)) ?? nil144 storage = (try? c.decodeIfPresent(Bool.self, forKey: .storage)) ?? nil145 floor = (try? c.decodeIfPresent(Int.self, forKey: .floor)) ?? nil146 priceFrom = (try? c.decodeIfPresent(Bool.self, forKey: .priceFrom)) ?? nil147 }148}149150// MARK: - Enrichissements de la fiche151152struct Poi: Decodable, Hashable {153 let cat: String154 let name: String155 let distM: Double156157 enum CodingKeys: String, CodingKey {158 case cat, name159 case distM = "dist_m"160 }161}162163struct Quartier: Decodable, Hashable {164 struct Demographie: Decodable, Hashable {165 var population: Double?166 var densite: Double?167 var ageMedian: Double?168 var revenuMedian: Double?169 var pctLocataires: Double?170 var loyerMoyen: Double?171172 enum CodingKeys: String, CodingKey {173 case population, densite174 case ageMedian = "age_median"175 case revenuMedian = "revenu_median"176 case pctLocataires = "pct_locataires"177 case loyerMoyen = "loyer_moyen"178 }179 }180181 struct Chaleur: Decodable, Hashable {182 var classe: Int?183 }184185 var demographie: Demographie?186 var chaleur: Chaleur?187188 enum CodingKeys: String, CodingKey { case demographie, chaleur }189190 init(from decoder: Decoder) throws {191 let c = try decoder.container(keyedBy: CodingKeys.self)192 demographie = (try? c.decodeIfPresent(Demographie.self, forKey: .demographie)) ?? nil193 chaleur = (try? c.decodeIfPresent(Chaleur.self, forKey: .chaleur)) ?? nil194 }195}196197struct Digest: Decodable, Hashable {198 struct Section: Decodable, Hashable {199 let titre: String200 let texte: String201 }202203 struct Faits: Decodable, Hashable {204 var electromenagers: [String]?205 var inclusions: [String]?206 var contraintes: [String]?207 }208209 var enBref: String?210 var texteNettoye: String?211 var sections: [Section]?212 var faits: Faits?213214 enum CodingKeys: String, CodingKey {215 case sections, faits216 case enBref = "en_bref"217 case texteNettoye = "texte_nettoye"218 }219220 init(from decoder: Decoder) throws {221 let c = try decoder.container(keyedBy: CodingKeys.self)222 enBref = (try? c.decodeIfPresent(String.self, forKey: .enBref)) ?? nil223 texteNettoye = (try? c.decodeIfPresent(String.self, forKey: .texteNettoye)) ?? nil224 sections = (try? c.decodeIfPresent([Section].self, forKey: .sections)) ?? nil225 faits = (try? c.decodeIfPresent(Faits.self, forKey: .faits)) ?? nil226 }227}228229// MARK: - Facettes, sources, stats230231struct Facets: Decodable {232 struct SourceCount: Decodable, Hashable {233 let source: String234 let n: Int235 }236237 let cities: [String]238 let sectors: [String]239 let unitTypes: [String]240 let sources: [SourceCount]241242 enum CodingKeys: String, CodingKey {243 case cities, sectors, sources244 case unitTypes = "unit_types"245 }246}247248struct SourcesResponse: Decodable {249 let sources: [SourceInfo]250}251252struct SourceInfo: Identifiable, Decodable, Hashable {253 let id: String254 let name: String255 let url: String256 let sectors: String?257 let status: String258 let activeListings: Int259 let lastSync: Double?260261 enum CodingKeys: String, CodingKey {262 case id, name, url, sectors, status263 case activeListings = "active_listings"264 case lastSync = "last_sync"265 }266267 init(from decoder: Decoder) throws {268 let c = try decoder.container(keyedBy: CodingKeys.self)269 id = try c.decode(String.self, forKey: .id)270 name = (try? c.decode(String.self, forKey: .name)) ?? id271 url = (try? c.decode(String.self, forKey: .url)) ?? ""272 // « sectors » est tantôt une chaîne, tantôt une liste selon la source273 if let s = try? c.decodeIfPresent(String.self, forKey: .sectors) {274 sectors = s275 } else if let l = try? c.decodeIfPresent([String].self, forKey: .sectors) {276 sectors = l.joined(separator: ", ")277 } else {278 sectors = nil279 }280 status = (try? c.decode(String.self, forKey: .status)) ?? ""281 activeListings = (try? c.decode(Int.self, forKey: .activeListings)) ?? 0282 lastSync = (try? c.decodeIfPresent(Double.self, forKey: .lastSync)) ?? nil283 }284}285286struct Stats: Decodable {287 let total: Int288 let quebec: Int289 let levis: Int290 let montreal: Int291 let autres: Int292 let sources: Int293 let avgPrice: Double?294295 enum CodingKeys: String, CodingKey {296 case total, quebec, levis, montreal, autres, sources297 case avgPrice = "avg_price"298 }299}300301// MARK: - Stats détaillées (/api/stats/detailed)302303struct DetailedStats: Decodable {304 struct Totals: Decodable {305 var total: Int?306 var withPrice: Int?307 var avg: Double?308 var median: Double?309 var min: Double?310 var max: Double?311 var sources: Int?312 var cities: Int?313 var regions: Int?314 var dispoNow: Int?315316 enum CodingKeys: String, CodingKey {317 case total, avg, median, min, max, sources, cities, regions318 case withPrice = "with_price"319 case dispoNow = "dispo_now"320 }321 }322323 struct Bucket: Decodable, Hashable {324 let lo: Double325 let hi: Double?326 let count: Int327 }328329 struct Group: Decodable, Hashable {330 let key: String331 let count: Int332 var avgPrice: Double?333 var minPrice: Double?334335 enum CodingKeys: String, CodingKey {336 case key, count337 case avgPrice = "avg_price"338 case minPrice = "min_price"339 }340 }341342 struct Offre: Decodable {343 var furnishedPct: Double?344 var petsOuiPct: Double?345 var chauffagePct: Double?346 var electricitePct: Double?347 var internetPct: Double?348 var climPct: Double?349 var stationnementPct: Double?350 var balconPct: Double?351 var dispoNow: Int?352353 enum CodingKeys: String, CodingKey {354 case furnishedPct = "furnished_pct"355 case petsOuiPct = "pets_oui_pct"356 case chauffagePct = "chauffage_pct"357 case electricitePct = "electricite_pct"358 case internetPct = "internet_pct"359 case climPct = "clim_pct"360 case stationnementPct = "stationnement_pct"361 case balconPct = "balcon_pct"362 case dispoNow = "dispo_now"363 }364 }365366 struct Baisse: Decodable, Hashable {367 let uid: String368 let title: String369 let city: String370 let avant: Double371 let apres: Double372 let pct: Double373 }374375 var totals: Totals?376 var histogram: [Bucket]?377 var byType: [Group]?378 var byCity: [Group]?379 var byRegion: [Group]?380 var bySource: [Group]?381 var offre: Offre?382 var baisses: [Baisse]?383384 enum CodingKeys: String, CodingKey {385 case totals, histogram, offre, baisses386 case byType = "by_type"387 case byCity = "by_city"388 case byRegion = "by_region"389 case bySource = "by_source"390 }391392 init(from decoder: Decoder) throws {393 let c = try decoder.container(keyedBy: CodingKeys.self)394 totals = (try? c.decodeIfPresent(Totals.self, forKey: .totals)) ?? nil395 histogram = (try? c.decodeIfPresent([Bucket].self, forKey: .histogram)) ?? nil396 byType = (try? c.decodeIfPresent([Group].self, forKey: .byType)) ?? nil397 byCity = (try? c.decodeIfPresent([Group].self, forKey: .byCity)) ?? nil398 byRegion = (try? c.decodeIfPresent([Group].self, forKey: .byRegion)) ?? nil399 bySource = (try? c.decodeIfPresent([Group].self, forKey: .bySource)) ?? nil400 offre = (try? c.decodeIfPresent(Offre.self, forKey: .offre)) ?? nil401 baisses = (try? c.decodeIfPresent([Baisse].self, forKey: .baisses)) ?? nil402 }403}404