SPB Git

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%
13.5 KB · 404 lines swift
Raw Blame History
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