// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Models.swift : modèles décodés depuis l'API (miroir de frontend/src/api.ts) // Décodage volontairement tolérant : un champ inattendu chez un connecteur // ne doit jamais faire disparaître l'annonce entière de l'app. // ----------------------------------------------------------------------------- import Foundation // MARK: - Annonce struct ListingsResponse: Decodable { let total: Int let listings: [Listing] } struct Listing: Identifiable, Decodable, Hashable { let uid: String let source: String let url: String let title: String let address: String let sector: String let city: String let unitType: String let price: Double? let priceLabel: String let availability: String let availabilityDate: String? let areaSqft: Double? let pets: String? let furnished: Bool? let descriptionText: String let amenities: [String] let details: ListingDetails? let images: [String] let lat: Double? let lng: Double? // fiche complète seulement let poi: [Poi]? let quartier: Quartier? let digest: Digest? let priceHistory: [PricePoint]? let lastSeen: Double? var id: String { uid } static func == (lhs: Listing, rhs: Listing) -> Bool { lhs.uid == rhs.uid } func hash(into hasher: inout Hasher) { hasher.combine(uid) } enum CodingKeys: String, CodingKey { case uid, source, url, title, address, sector, city, price, availability case pets, furnished, amenities, details, images, lat, lng, poi, quartier, digest case unitType = "unit_type" case priceLabel = "price_label" case availabilityDate = "availability_date" case areaSqft = "area_sqft" case descriptionText = "description" case priceHistory = "price_history" case lastSeen = "last_seen" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) uid = try c.decode(String.self, forKey: .uid) source = (try? c.decode(String.self, forKey: .source)) ?? "" url = (try? c.decode(String.self, forKey: .url)) ?? "" title = (try? c.decode(String.self, forKey: .title)) ?? "" address = (try? c.decode(String.self, forKey: .address)) ?? "" sector = (try? c.decode(String.self, forKey: .sector)) ?? "" city = (try? c.decode(String.self, forKey: .city)) ?? "" unitType = (try? c.decode(String.self, forKey: .unitType)) ?? "" price = (try? c.decodeIfPresent(Double.self, forKey: .price)) ?? nil priceLabel = (try? c.decode(String.self, forKey: .priceLabel)) ?? "" availability = (try? c.decode(String.self, forKey: .availability)) ?? "" availabilityDate = (try? c.decodeIfPresent(String.self, forKey: .availabilityDate)) ?? nil areaSqft = (try? c.decodeIfPresent(Double.self, forKey: .areaSqft)) ?? nil pets = (try? c.decodeIfPresent(String.self, forKey: .pets)) ?? nil furnished = (try? c.decodeIfPresent(Bool.self, forKey: .furnished)) ?? nil descriptionText = (try? c.decode(String.self, forKey: .descriptionText)) ?? "" amenities = (try? c.decode([String].self, forKey: .amenities)) ?? [] details = (try? c.decodeIfPresent(ListingDetails.self, forKey: .details)) ?? nil images = (try? c.decode([String].self, forKey: .images)) ?? [] lat = (try? c.decodeIfPresent(Double.self, forKey: .lat)) ?? nil lng = (try? c.decodeIfPresent(Double.self, forKey: .lng)) ?? nil poi = (try? c.decodeIfPresent([Poi].self, forKey: .poi)) ?? nil quartier = (try? c.decodeIfPresent(Quartier.self, forKey: .quartier)) ?? nil digest = (try? c.decodeIfPresent(Digest.self, forKey: .digest)) ?? nil priceHistory = (try? c.decodeIfPresent([PricePoint].self, forKey: .priceHistory)) ?? nil lastSeen = (try? c.decodeIfPresent(Double.self, forKey: .lastSeen)) ?? nil } } struct PricePoint: Decodable, Hashable { let ts: Double let price: Double? } struct ListingDetails: Decodable, Hashable { var inclusions: [String: Bool]? var appliances: [String: Bool]? var parking: Parking? var contact: Contact? var ac: Bool? var elevator: Bool? var balcony: Bool? var pool: Bool? var gym: Bool? var laundry: Bool? var storage: Bool? var floor: Int? var priceFrom: Bool? struct Parking: Decodable, Hashable { var available: Bool? var type: String? var included: Bool? var price: Double? } struct Contact: Decodable, Hashable { var phone: String? var email: String? } enum CodingKeys: String, CodingKey { case inclusions, appliances, parking, contact case ac, elevator, balcony, pool, gym, laundry, storage, floor case priceFrom = "price_from" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) inclusions = (try? c.decodeIfPresent([String: Bool].self, forKey: .inclusions)) ?? nil appliances = (try? c.decodeIfPresent([String: Bool].self, forKey: .appliances)) ?? nil parking = (try? c.decodeIfPresent(Parking.self, forKey: .parking)) ?? nil contact = (try? c.decodeIfPresent(Contact.self, forKey: .contact)) ?? nil ac = (try? c.decodeIfPresent(Bool.self, forKey: .ac)) ?? nil elevator = (try? c.decodeIfPresent(Bool.self, forKey: .elevator)) ?? nil balcony = (try? c.decodeIfPresent(Bool.self, forKey: .balcony)) ?? nil pool = (try? c.decodeIfPresent(Bool.self, forKey: .pool)) ?? nil gym = (try? c.decodeIfPresent(Bool.self, forKey: .gym)) ?? nil laundry = (try? c.decodeIfPresent(Bool.self, forKey: .laundry)) ?? nil storage = (try? c.decodeIfPresent(Bool.self, forKey: .storage)) ?? nil floor = (try? c.decodeIfPresent(Int.self, forKey: .floor)) ?? nil priceFrom = (try? c.decodeIfPresent(Bool.self, forKey: .priceFrom)) ?? nil } } // MARK: - Enrichissements de la fiche struct Poi: Decodable, Hashable { let cat: String let name: String let distM: Double enum CodingKeys: String, CodingKey { case cat, name case distM = "dist_m" } } struct Quartier: Decodable, Hashable { struct Demographie: Decodable, Hashable { var population: Double? var densite: Double? var ageMedian: Double? var revenuMedian: Double? var pctLocataires: Double? var loyerMoyen: Double? enum CodingKeys: String, CodingKey { case population, densite case ageMedian = "age_median" case revenuMedian = "revenu_median" case pctLocataires = "pct_locataires" case loyerMoyen = "loyer_moyen" } } struct Chaleur: Decodable, Hashable { var classe: Int? } var demographie: Demographie? var chaleur: Chaleur? enum CodingKeys: String, CodingKey { case demographie, chaleur } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) demographie = (try? c.decodeIfPresent(Demographie.self, forKey: .demographie)) ?? nil chaleur = (try? c.decodeIfPresent(Chaleur.self, forKey: .chaleur)) ?? nil } } struct Digest: Decodable, Hashable { struct Section: Decodable, Hashable { let titre: String let texte: String } struct Faits: Decodable, Hashable { var electromenagers: [String]? var inclusions: [String]? var contraintes: [String]? } var enBref: String? var texteNettoye: String? var sections: [Section]? var faits: Faits? enum CodingKeys: String, CodingKey { case sections, faits case enBref = "en_bref" case texteNettoye = "texte_nettoye" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) enBref = (try? c.decodeIfPresent(String.self, forKey: .enBref)) ?? nil texteNettoye = (try? c.decodeIfPresent(String.self, forKey: .texteNettoye)) ?? nil sections = (try? c.decodeIfPresent([Section].self, forKey: .sections)) ?? nil faits = (try? c.decodeIfPresent(Faits.self, forKey: .faits)) ?? nil } } // MARK: - Facettes, sources, stats struct Facets: Decodable { struct SourceCount: Decodable, Hashable { let source: String let n: Int } let cities: [String] let sectors: [String] let unitTypes: [String] let sources: [SourceCount] enum CodingKeys: String, CodingKey { case cities, sectors, sources case unitTypes = "unit_types" } } struct SourcesResponse: Decodable { let sources: [SourceInfo] } struct SourceInfo: Identifiable, Decodable, Hashable { let id: String let name: String let url: String let sectors: String? let status: String let activeListings: Int let lastSync: Double? enum CodingKeys: String, CodingKey { case id, name, url, sectors, status case activeListings = "active_listings" case lastSync = "last_sync" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) name = (try? c.decode(String.self, forKey: .name)) ?? id url = (try? c.decode(String.self, forKey: .url)) ?? "" // « sectors » est tantôt une chaîne, tantôt une liste selon la source if let s = try? c.decodeIfPresent(String.self, forKey: .sectors) { sectors = s } else if let l = try? c.decodeIfPresent([String].self, forKey: .sectors) { sectors = l.joined(separator: ", ") } else { sectors = nil } status = (try? c.decode(String.self, forKey: .status)) ?? "" activeListings = (try? c.decode(Int.self, forKey: .activeListings)) ?? 0 lastSync = (try? c.decodeIfPresent(Double.self, forKey: .lastSync)) ?? nil } } struct Stats: Decodable { let total: Int let quebec: Int let levis: Int let montreal: Int let autres: Int let sources: Int let avgPrice: Double? enum CodingKeys: String, CodingKey { case total, quebec, levis, montreal, autres, sources case avgPrice = "avg_price" } } // MARK: - Stats détaillées (/api/stats/detailed) struct DetailedStats: Decodable { struct Totals: Decodable { var total: Int? var withPrice: Int? var avg: Double? var median: Double? var min: Double? var max: Double? var sources: Int? var cities: Int? var regions: Int? var dispoNow: Int? enum CodingKeys: String, CodingKey { case total, avg, median, min, max, sources, cities, regions case withPrice = "with_price" case dispoNow = "dispo_now" } } struct Bucket: Decodable, Hashable { let lo: Double let hi: Double? let count: Int } struct Group: Decodable, Hashable { let key: String let count: Int var avgPrice: Double? var minPrice: Double? enum CodingKeys: String, CodingKey { case key, count case avgPrice = "avg_price" case minPrice = "min_price" } } struct Offre: Decodable { var furnishedPct: Double? var petsOuiPct: Double? var chauffagePct: Double? var electricitePct: Double? var internetPct: Double? var climPct: Double? var stationnementPct: Double? var balconPct: Double? var dispoNow: Int? enum CodingKeys: String, CodingKey { case furnishedPct = "furnished_pct" case petsOuiPct = "pets_oui_pct" case chauffagePct = "chauffage_pct" case electricitePct = "electricite_pct" case internetPct = "internet_pct" case climPct = "clim_pct" case stationnementPct = "stationnement_pct" case balconPct = "balcon_pct" case dispoNow = "dispo_now" } } struct Baisse: Decodable, Hashable { let uid: String let title: String let city: String let avant: Double let apres: Double let pct: Double } var totals: Totals? var histogram: [Bucket]? var byType: [Group]? var byCity: [Group]? var byRegion: [Group]? var bySource: [Group]? var offre: Offre? var baisses: [Baisse]? enum CodingKeys: String, CodingKey { case totals, histogram, offre, baisses case byType = "by_type" case byCity = "by_city" case byRegion = "by_region" case bySource = "by_source" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) totals = (try? c.decodeIfPresent(Totals.self, forKey: .totals)) ?? nil histogram = (try? c.decodeIfPresent([Bucket].self, forKey: .histogram)) ?? nil byType = (try? c.decodeIfPresent([Group].self, forKey: .byType)) ?? nil byCity = (try? c.decodeIfPresent([Group].self, forKey: .byCity)) ?? nil byRegion = (try? c.decodeIfPresent([Group].self, forKey: .byRegion)) ?? nil bySource = (try? c.decodeIfPresent([Group].self, forKey: .bySource)) ?? nil offre = (try? c.decodeIfPresent(Offre.self, forKey: .offre)) ?? nil baisses = (try? c.decodeIfPresent([Baisse].self, forKey: .baisses)) ?? nil } }