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%

Lou·Ka iOS 1.2 — app SwiftUI native complète

- 5 onglets : Annonces (filtres avancés + pastilles actives), Découvrir
  (swipe plein écran + moteur de recommandation on-device Reco.swift),
  Carte (MapKit + presets de ville), Stats (KPI, histogramme, palmarès),
  Sources (registre des 255 gestionnaires)
- Fiche complète : galerie, faits, inclusions, digest, carte, POI, quartier,
  lien vers l'annonce originale
- Design « éditorial sharp » du site : papier/encre/lime, ombres décalées,
  Space Grotesk (instances statiques fontTools) + JetBrains Mono embarquées,
  thème clair forcé ; icône dans le langage Vrai-Prix (Lou / boîte Ka / QUÉBEC)
- Projet XcodeGen (project.yml), zéro dépendance externe, iOS 17+
- Pipeline TestFlight en CLI (archive + exportArchive) — build 1.2 (3) publiée

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 4 h ago (Aug 11, 2026)

Showing 33 changed files with +3,500 and −0

added .gitignore +10 −0
@@ -0,0 +1,10 @@
1 +# Xcode — projet généré par XcodeGen (régénérer : xcodegen generate)
2 +LouKa.xcodeproj/
3 +build/
4 +DerivedData/
5 +*.xcarchive
6 +xcuserdata/
7 +*.xcuserstate
8 +
9 +# macOS
10 +.DS_Store
added ExportOptions.plist +18 −0
@@ -0,0 +1,18 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<plist version="1.0">
4 +<dict>
5 + <key>method</key>
6 + <string>app-store-connect</string>
7 + <key>destination</key>
8 + <string>upload</string>
9 + <key>teamID</key>
10 + <string>3YM54G49SN</string>
11 + <key>signingStyle</key>
12 + <string>automatic</string>
13 + <key>uploadSymbols</key>
14 + <true/>
15 + <key>manageAppVersionAndBuildNumber</key>
16 + <true/>
17 +</dict>
18 +</plist>
added LouKa/API.swift +96 −0
@@ -0,0 +1,96 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// API.swift : client HTTP vers l'API de production (www.lou-ka.com)
5 +// -----------------------------------------------------------------------------
6 +import Foundation
7 +
8 +struct ListingFilters: Equatable {
9 + var q = ""
10 + var city = ""
11 + var sector = ""
12 + var unitType = ""
13 + var source = ""
14 + var priceMin: Int?
15 + var priceMax: Int?
16 + /// nil = peu importe · 0 = maintenant · 30/60/90 = d'ici n jours
17 + var dispoDays: Int?
18 + var petsOk = false
19 + /// nil = peu importe · true = meublé · false = non meublé
20 + var furnished: Bool?
21 + var areaMin: Int?
22 +
23 + var activeCount: Int {
24 + [q, city, sector, unitType, source].filter { !$0.isEmpty }.count
25 + + (priceMin != nil ? 1 : 0) + (priceMax != nil ? 1 : 0)
26 + + (dispoDays != nil ? 1 : 0) + (petsOk ? 1 : 0)
27 + + (furnished != nil ? 1 : 0) + (areaMin != nil ? 1 : 0)
28 + }
29 +}
30 +
31 +enum APIError: LocalizedError {
32 + case badStatus(Int)
33 +
34 + var errorDescription: String? {
35 + switch self {
36 + case .badStatus(let code): return "L'API a répondu \(code). Réessayez dans un instant."
37 + }
38 + }
39 +}
40 +
41 +enum API {
42 + static let base = URL(string: "https://www.lou-ka.com")!
43 +
44 + /// Date ISO à +n jours (paramètre `available_by`)
45 + static func isoInDays(_ n: Int) -> String {
46 + let d = Calendar.current.date(byAdding: .day, value: n, to: Date()) ?? Date()
47 + let f = DateFormatter()
48 + f.dateFormat = "yyyy-MM-dd"
49 + f.locale = Locale(identifier: "en_US_POSIX")
50 + return f.string(from: d)
51 + }
52 +
53 + static func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
54 + var comps = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
55 + if !query.isEmpty { comps.queryItems = query }
56 + var req = URLRequest(url: comps.url!)
57 + req.timeoutInterval = 20
58 + req.setValue("LouKa-iOS/1.2", forHTTPHeaderField: "User-Agent")
59 + let (data, resp) = try await URLSession.shared.data(for: req)
60 + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
61 + throw APIError.badStatus((resp as? HTTPURLResponse)?.statusCode ?? -1)
62 + }
63 + return try JSONDecoder().decode(T.self, from: data)
64 + }
65 +
66 + static func listings(_ f: ListingFilters, limit: Int? = nil) async throws -> ListingsResponse {
67 + var q: [URLQueryItem] = []
68 + if !f.q.isEmpty { q.append(.init(name: "q", value: f.q)) }
69 + if !f.city.isEmpty { q.append(.init(name: "city", value: f.city)) }
70 + if !f.sector.isEmpty { q.append(.init(name: "sector", value: f.sector)) }
71 + if !f.unitType.isEmpty { q.append(.init(name: "unit_type", value: f.unitType)) }
72 + if !f.source.isEmpty { q.append(.init(name: "source", value: f.source)) }
73 + if let min = f.priceMin { q.append(.init(name: "price_min", value: String(min))) }
74 + if let max = f.priceMax { q.append(.init(name: "price_max", value: String(max))) }
75 + if f.petsOk { q.append(.init(name: "pets", value: "oui")) }
76 + if let furn = f.furnished { q.append(.init(name: "furnished", value: furn ? "1" : "0")) }
77 + if let days = f.dispoDays { q.append(.init(name: "available_by", value: isoInDays(days))) }
78 + if let area = f.areaMin { q.append(.init(name: "area_min", value: String(area))) }
79 + if let limit { q.append(.init(name: "limit", value: String(limit))) }
80 + return try await get("api/listings", query: q)
81 + }
82 +
83 + static func listing(uid: String) async throws -> Listing {
84 + try await get("api/listings/\(uid)")
85 + }
86 +
87 + static func facets(city: String? = nil) async throws -> Facets {
88 + var q: [URLQueryItem] = []
89 + if let city, !city.isEmpty { q.append(.init(name: "city", value: city)) }
90 + return try await get("api/facets", query: q)
91 + }
92 +
93 + static func sources() async throws -> SourcesResponse { try await get("api/sources") }
94 + static func stats() async throws -> Stats { try await get("api/stats") }
95 + static func detailedStats() async throws -> DetailedStats { try await get("api/stats/detailed") }
96 +}
added LouKa/App.swift +90 −0
@@ -0,0 +1,90 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.swift : point d'entrée, modèle global (facettes / sources / stats), onglets
5 +// -----------------------------------------------------------------------------
6 +import SwiftUI
7 +import Observation
8 +
9 +@MainActor
10 +@Observable
11 +final class AppModel {
12 + var facets: Facets?
13 + var sources: [SourceInfo] = []
14 + var stats: Stats?
15 +
16 + private var loaded = false
17 +
18 + func loadGlobals(force: Bool = false) async {
19 + if loaded && !force { return }
20 + loaded = true
21 + async let facetsTask = try? API.facets()
22 + async let sourcesTask = try? API.sources()
23 + async let statsTask = try? API.stats()
24 + let (f, s, st) = await (facetsTask, sourcesTask, statsTask)
25 + if let f { facets = f }
26 + if let s { sources = s.sources }
27 + if let st { stats = st }
28 + }
29 +
30 + /// id de source → nom lisible (ex. « logisco » → « Logisco »)
31 + func sourceName(_ id: String) -> String {
32 + sources.first(where: { $0.id == id })?.name ?? id
33 + }
34 +}
35 +
36 +@main
37 +struct LouKaApp: App {
38 + @State private var model = AppModel()
39 + @State private var reco = RecoEngine()
40 +
41 + init() {
42 + // Barre de navigation « éditoriale » : papier opaque, trait encre,
43 + // titres en Space Grotesk — cohérent sur toutes les pages.
44 + let nav = UINavigationBarAppearance()
45 + nav.configureWithOpaqueBackground()
46 + nav.backgroundColor = UIColor(LK.paper)
47 + nav.shadowColor = UIColor(LK.ink)
48 + var titleAttrs: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor(LK.ink)]
49 + var largeAttrs = titleAttrs
50 + if let f = UIFont(name: "SpaceGrotesk-Bold", size: 17) { titleAttrs[.font] = f }
51 + if let f = UIFont(name: "SpaceGrotesk-Bold", size: 30) { largeAttrs[.font] = f }
52 + nav.titleTextAttributes = titleAttrs
53 + nav.largeTitleTextAttributes = largeAttrs
54 + UINavigationBar.appearance().standardAppearance = nav
55 + UINavigationBar.appearance().scrollEdgeAppearance = nav
56 + }
57 +
58 + var body: some Scene {
59 + WindowGroup {
60 + RootView()
61 + .environment(model)
62 + .environment(reco)
63 + .tint(LK.ink)
64 + // Le design Lou-Ka est un thème clair (papier/encre) : on le force
65 + // pour que les couleurs système (titres, champs, pickers) ne passent
66 + // jamais au blanc en mode sombre → texte invisible.
67 + .preferredColorScheme(.light)
68 + }
69 + }
70 +}
71 +
72 +struct RootView: View {
73 + @Environment(AppModel.self) private var model
74 +
75 + var body: some View {
76 + TabView {
77 + HomeView()
78 + .tabItem { Label("Annonces", systemImage: "list.bullet.rectangle.portrait") }
79 + DiscoverView()
80 + .tabItem { Label("Découvrir", systemImage: "rectangle.stack.badge.play") }
81 + MapTabView()
82 + .tabItem { Label("Carte", systemImage: "map") }
83 + StatsView()
84 + .tabItem { Label("Stats", systemImage: "chart.bar.xaxis") }
85 + SourcesView()
86 + .tabItem { Label("Sources", systemImage: "building.2") }
87 + }
88 + .task { await model.loadGlobals() }
89 + }
90 +}
added LouKa/Assets.xcassets/AccentColor.colorset/Contents.json +12 −0
@@ -0,0 +1,12 @@
1 +{
2 + "colors" : [
3 + {
4 + "color" : {
5 + "color-space" : "srgb",
6 + "components" : { "alpha" : "1.000", "blue" : "0x41", "green" : "0x5C", "red" : "0x1C" }
7 + },
8 + "idiom" : "universal"
9 + }
10 + ],
11 + "info" : { "author" : "xcode", "version" : 1 }
12 +}
added LouKa/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 −0

Binary file not shown.

added LouKa/Assets.xcassets/AppIcon.appiconset/Contents.json +11 −0
@@ -0,0 +1,11 @@
1 +{
2 + "images" : [
3 + {
4 + "filename" : "AppIcon-1024.png",
5 + "idiom" : "universal",
6 + "platform" : "ios",
7 + "size" : "1024x1024"
8 + }
9 + ],
10 + "info" : { "author" : "xcode", "version" : 1 }
11 +}
added LouKa/Assets.xcassets/Contents.json +3 −0
@@ -0,0 +1,3 @@
1 +{
2 + "info" : { "author" : "xcode", "version" : 1 }
3 +}
added LouKa/Assets.xcassets/LaunchBackground.colorset/Contents.json +12 −0
@@ -0,0 +1,12 @@
1 +{
2 + "colors" : [
3 + {
4 + "color" : {
5 + "color-space" : "srgb",
6 + "components" : { "alpha" : "1.000", "blue" : "0xEE", "green" : "0xF3", "red" : "0xF5" }
7 + },
8 + "idiom" : "universal"
9 + }
10 + ],
11 + "info" : { "author" : "xcode", "version" : 1 }
12 +}
added LouKa/Fonts/JetBrainsMono-Bold.ttf +0 −0

Binary file not shown.

added LouKa/Fonts/JetBrainsMono-Medium.ttf +0 −0

Binary file not shown.

added LouKa/Fonts/JetBrainsMono-Regular.ttf +0 −0

Binary file not shown.

added LouKa/Fonts/SpaceGrotesk-Bold.ttf +0 −0

Binary file not shown.

added LouKa/Fonts/SpaceGrotesk-Medium.ttf +0 −0

Binary file not shown.

added LouKa/Fonts/SpaceGrotesk-Regular.ttf +0 −0

Binary file not shown.

added LouKa/Info.plist +58 −0
@@ -0,0 +1,58 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<plist version="1.0">
4 +<dict>
5 + <key>CFBundleDevelopmentRegion</key>
6 + <string>$(DEVELOPMENT_LANGUAGE)</string>
7 + <key>CFBundleDisplayName</key>
8 + <string>Lou·Ka</string>
9 + <key>CFBundleExecutable</key>
10 + <string>$(EXECUTABLE_NAME)</string>
11 + <key>CFBundleIdentifier</key>
12 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 + <key>CFBundleInfoDictionaryVersion</key>
14 + <string>6.0</string>
15 + <key>CFBundleName</key>
16 + <string>$(PRODUCT_NAME)</string>
17 + <key>CFBundlePackageType</key>
18 + <string>APPL</string>
19 + <key>CFBundleShortVersionString</key>
20 + <string>$(MARKETING_VERSION)</string>
21 + <key>CFBundleVersion</key>
22 + <string>$(CURRENT_PROJECT_VERSION)</string>
23 + <key>ITSAppUsesNonExemptEncryption</key>
24 + <false/>
25 + <key>NSAppTransportSecurity</key>
26 + <dict>
27 + <key>NSAllowsArbitraryLoads</key>
28 + <true/>
29 + </dict>
30 + <key>UIAppFonts</key>
31 + <array>
32 + <string>SpaceGrotesk-Regular.ttf</string>
33 + <string>SpaceGrotesk-Medium.ttf</string>
34 + <string>SpaceGrotesk-Bold.ttf</string>
35 + <string>JetBrainsMono-Regular.ttf</string>
36 + <string>JetBrainsMono-Medium.ttf</string>
37 + <string>JetBrainsMono-Bold.ttf</string>
38 + </array>
39 + <key>UILaunchScreen</key>
40 + <dict>
41 + <key>UIColorName</key>
42 + <string>LaunchBackground</string>
43 + </dict>
44 + <key>UISupportedInterfaceOrientations</key>
45 + <array>
46 + <string>UIInterfaceOrientationPortrait</string>
47 + </array>
48 + <key>UISupportedInterfaceOrientations~ipad</key>
49 + <array>
50 + <string>UIInterfaceOrientationPortrait</string>
51 + <string>UIInterfaceOrientationPortraitUpsideDown</string>
52 + <string>UIInterfaceOrientationLandscapeLeft</string>
53 + <string>UIInterfaceOrientationLandscapeRight</string>
54 + </array>
55 + <key>UIUserInterfaceStyle</key>
56 + <string>Light</string>
57 +</dict>
58 +</plist>
added LouKa/Models.swift +403 −0
@@ -0,0 +1,403 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// 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 connecteur
6 +// ne doit jamais faire disparaître l'annonce entière de l'app.
7 +// -----------------------------------------------------------------------------
8 +import Foundation
9 +
10 +// MARK: - Annonce
11 +
12 +struct ListingsResponse: Decodable {
13 + let total: Int
14 + let listings: [Listing]
15 +}
16 +
17 +struct Listing: Identifiable, Decodable, Hashable {
18 + let uid: String
19 + let source: String
20 + let url: String
21 + let title: String
22 + let address: String
23 + let sector: String
24 + let city: String
25 + let unitType: String
26 + let price: Double?
27 + let priceLabel: String
28 + let availability: String
29 + let availabilityDate: String?
30 + let areaSqft: Double?
31 + let pets: String?
32 + let furnished: Bool?
33 + let descriptionText: String
34 + let amenities: [String]
35 + let details: ListingDetails?
36 + let images: [String]
37 + let lat: Double?
38 + let lng: Double?
39 + // fiche complète seulement
40 + let poi: [Poi]?
41 + let quartier: Quartier?
42 + let digest: Digest?
43 + let priceHistory: [PricePoint]?
44 + let lastSeen: Double?
45 +
46 + var id: String { uid }
47 +
48 + static func == (lhs: Listing, rhs: Listing) -> Bool { lhs.uid == rhs.uid }
49 + func hash(into hasher: inout Hasher) { hasher.combine(uid) }
50 +
51 + enum CodingKeys: String, CodingKey {
52 + case uid, source, url, title, address, sector, city, price, availability
53 + case pets, furnished, amenities, details, images, lat, lng, poi, quartier, digest
54 + 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 + }
62 +
63 + 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)) ?? nil
74 + 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)) ?? nil
77 + areaSqft = (try? c.decodeIfPresent(Double.self, forKey: .areaSqft)) ?? nil
78 + pets = (try? c.decodeIfPresent(String.self, forKey: .pets)) ?? nil
79 + furnished = (try? c.decodeIfPresent(Bool.self, forKey: .furnished)) ?? nil
80 + 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)) ?? nil
83 + images = (try? c.decode([String].self, forKey: .images)) ?? []
84 + lat = (try? c.decodeIfPresent(Double.self, forKey: .lat)) ?? nil
85 + lng = (try? c.decodeIfPresent(Double.self, forKey: .lng)) ?? nil
86 + poi = (try? c.decodeIfPresent([Poi].self, forKey: .poi)) ?? nil
87 + quartier = (try? c.decodeIfPresent(Quartier.self, forKey: .quartier)) ?? nil
88 + digest = (try? c.decodeIfPresent(Digest.self, forKey: .digest)) ?? nil
89 + priceHistory = (try? c.decodeIfPresent([PricePoint].self, forKey: .priceHistory)) ?? nil
90 + lastSeen = (try? c.decodeIfPresent(Double.self, forKey: .lastSeen)) ?? nil
91 + }
92 +}
93 +
94 +struct PricePoint: Decodable, Hashable {
95 + let ts: Double
96 + let price: Double?
97 +}
98 +
99 +struct 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?
113 +
114 + struct Parking: Decodable, Hashable {
115 + var available: Bool?
116 + var type: String?
117 + var included: Bool?
118 + var price: Double?
119 + }
120 +
121 + struct Contact: Decodable, Hashable {
122 + var phone: String?
123 + var email: String?
124 + }
125 +
126 + enum CodingKeys: String, CodingKey {
127 + case inclusions, appliances, parking, contact
128 + case ac, elevator, balcony, pool, gym, laundry, storage, floor
129 + case priceFrom = "price_from"
130 + }
131 +
132 + init(from decoder: Decoder) throws {
133 + let c = try decoder.container(keyedBy: CodingKeys.self)
134 + inclusions = (try? c.decodeIfPresent([String: Bool].self, forKey: .inclusions)) ?? nil
135 + appliances = (try? c.decodeIfPresent([String: Bool].self, forKey: .appliances)) ?? nil
136 + parking = (try? c.decodeIfPresent(Parking.self, forKey: .parking)) ?? nil
137 + contact = (try? c.decodeIfPresent(Contact.self, forKey: .contact)) ?? nil
138 + ac = (try? c.decodeIfPresent(Bool.self, forKey: .ac)) ?? nil
139 + elevator = (try? c.decodeIfPresent(Bool.self, forKey: .elevator)) ?? nil
140 + balcony = (try? c.decodeIfPresent(Bool.self, forKey: .balcony)) ?? nil
141 + pool = (try? c.decodeIfPresent(Bool.self, forKey: .pool)) ?? nil
142 + gym = (try? c.decodeIfPresent(Bool.self, forKey: .gym)) ?? nil
143 + laundry = (try? c.decodeIfPresent(Bool.self, forKey: .laundry)) ?? nil
144 + storage = (try? c.decodeIfPresent(Bool.self, forKey: .storage)) ?? nil
145 + floor = (try? c.decodeIfPresent(Int.self, forKey: .floor)) ?? nil
146 + priceFrom = (try? c.decodeIfPresent(Bool.self, forKey: .priceFrom)) ?? nil
147 + }
148 +}
149 +
150 +// MARK: - Enrichissements de la fiche
151 +
152 +struct Poi: Decodable, Hashable {
153 + let cat: String
154 + let name: String
155 + let distM: Double
156 +
157 + enum CodingKeys: String, CodingKey {
158 + case cat, name
159 + case distM = "dist_m"
160 + }
161 +}
162 +
163 +struct 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?
171 +
172 + enum CodingKeys: String, CodingKey {
173 + case population, densite
174 + case ageMedian = "age_median"
175 + case revenuMedian = "revenu_median"
176 + case pctLocataires = "pct_locataires"
177 + case loyerMoyen = "loyer_moyen"
178 + }
179 + }
180 +
181 + struct Chaleur: Decodable, Hashable {
182 + var classe: Int?
183 + }
184 +
185 + var demographie: Demographie?
186 + var chaleur: Chaleur?
187 +
188 + enum CodingKeys: String, CodingKey { case demographie, chaleur }
189 +
190 + init(from decoder: Decoder) throws {
191 + let c = try decoder.container(keyedBy: CodingKeys.self)
192 + demographie = (try? c.decodeIfPresent(Demographie.self, forKey: .demographie)) ?? nil
193 + chaleur = (try? c.decodeIfPresent(Chaleur.self, forKey: .chaleur)) ?? nil
194 + }
195 +}
196 +
197 +struct Digest: Decodable, Hashable {
198 + struct Section: Decodable, Hashable {
199 + let titre: String
200 + let texte: String
201 + }
202 +
203 + struct Faits: Decodable, Hashable {
204 + var electromenagers: [String]?
205 + var inclusions: [String]?
206 + var contraintes: [String]?
207 + }
208 +
209 + var enBref: String?
210 + var texteNettoye: String?
211 + var sections: [Section]?
212 + var faits: Faits?
213 +
214 + enum CodingKeys: String, CodingKey {
215 + case sections, faits
216 + case enBref = "en_bref"
217 + case texteNettoye = "texte_nettoye"
218 + }
219 +
220 + init(from decoder: Decoder) throws {
221 + let c = try decoder.container(keyedBy: CodingKeys.self)
222 + enBref = (try? c.decodeIfPresent(String.self, forKey: .enBref)) ?? nil
223 + texteNettoye = (try? c.decodeIfPresent(String.self, forKey: .texteNettoye)) ?? nil
224 + sections = (try? c.decodeIfPresent([Section].self, forKey: .sections)) ?? nil
225 + faits = (try? c.decodeIfPresent(Faits.self, forKey: .faits)) ?? nil
226 + }
227 +}
228 +
229 +// MARK: - Facettes, sources, stats
230 +
231 +struct Facets: Decodable {
232 + struct SourceCount: Decodable, Hashable {
233 + let source: String
234 + let n: Int
235 + }
236 +
237 + let cities: [String]
238 + let sectors: [String]
239 + let unitTypes: [String]
240 + let sources: [SourceCount]
241 +
242 + enum CodingKeys: String, CodingKey {
243 + case cities, sectors, sources
244 + case unitTypes = "unit_types"
245 + }
246 +}
247 +
248 +struct SourcesResponse: Decodable {
249 + let sources: [SourceInfo]
250 +}
251 +
252 +struct SourceInfo: Identifiable, Decodable, Hashable {
253 + let id: String
254 + let name: String
255 + let url: String
256 + let sectors: String?
257 + let status: String
258 + let activeListings: Int
259 + let lastSync: Double?
260 +
261 + enum CodingKeys: String, CodingKey {
262 + case id, name, url, sectors, status
263 + case activeListings = "active_listings"
264 + case lastSync = "last_sync"
265 + }
266 +
267 + 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)) ?? id
271 + url = (try? c.decode(String.self, forKey: .url)) ?? ""
272 + // « sectors » est tantôt une chaîne, tantôt une liste selon la source
273 + if let s = try? c.decodeIfPresent(String.self, forKey: .sectors) {
274 + sectors = s
275 + } else if let l = try? c.decodeIfPresent([String].self, forKey: .sectors) {
276 + sectors = l.joined(separator: ", ")
277 + } else {
278 + sectors = nil
279 + }
280 + status = (try? c.decode(String.self, forKey: .status)) ?? ""
281 + activeListings = (try? c.decode(Int.self, forKey: .activeListings)) ?? 0
282 + lastSync = (try? c.decodeIfPresent(Double.self, forKey: .lastSync)) ?? nil
283 + }
284 +}
285 +
286 +struct Stats: Decodable {
287 + let total: Int
288 + let quebec: Int
289 + let levis: Int
290 + let montreal: Int
291 + let autres: Int
292 + let sources: Int
293 + let avgPrice: Double?
294 +
295 + enum CodingKeys: String, CodingKey {
296 + case total, quebec, levis, montreal, autres, sources
297 + case avgPrice = "avg_price"
298 + }
299 +}
300 +
301 +// MARK: - Stats détaillées (/api/stats/detailed)
302 +
303 +struct 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?
315 +
316 + enum CodingKeys: String, CodingKey {
317 + case total, avg, median, min, max, sources, cities, regions
318 + case withPrice = "with_price"
319 + case dispoNow = "dispo_now"
320 + }
321 + }
322 +
323 + struct Bucket: Decodable, Hashable {
324 + let lo: Double
325 + let hi: Double?
326 + let count: Int
327 + }
328 +
329 + struct Group: Decodable, Hashable {
330 + let key: String
331 + let count: Int
332 + var avgPrice: Double?
333 + var minPrice: Double?
334 +
335 + enum CodingKeys: String, CodingKey {
336 + case key, count
337 + case avgPrice = "avg_price"
338 + case minPrice = "min_price"
339 + }
340 + }
341 +
342 + 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?
352 +
353 + 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 + }
365 +
366 + struct Baisse: Decodable, Hashable {
367 + let uid: String
368 + let title: String
369 + let city: String
370 + let avant: Double
371 + let apres: Double
372 + let pct: Double
373 + }
374 +
375 + 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]?
383 +
384 + enum CodingKeys: String, CodingKey {
385 + case totals, histogram, offre, baisses
386 + case byType = "by_type"
387 + case byCity = "by_city"
388 + case byRegion = "by_region"
389 + case bySource = "by_source"
390 + }
391 +
392 + init(from decoder: Decoder) throws {
393 + let c = try decoder.container(keyedBy: CodingKeys.self)
394 + totals = (try? c.decodeIfPresent(Totals.self, forKey: .totals)) ?? nil
395 + histogram = (try? c.decodeIfPresent([Bucket].self, forKey: .histogram)) ?? nil
396 + byType = (try? c.decodeIfPresent([Group].self, forKey: .byType)) ?? nil
397 + byCity = (try? c.decodeIfPresent([Group].self, forKey: .byCity)) ?? nil
398 + byRegion = (try? c.decodeIfPresent([Group].self, forKey: .byRegion)) ?? nil
399 + bySource = (try? c.decodeIfPresent([Group].self, forKey: .bySource)) ?? nil
400 + offre = (try? c.decodeIfPresent(Offre.self, forKey: .offre)) ?? nil
401 + baisses = (try? c.decodeIfPresent([Baisse].self, forKey: .baisses)) ?? nil
402 + }
403 +}
added LouKa/Reco.swift +185 −0
@@ -0,0 +1,185 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Reco.swift : moteur de recommandation on-device du mode Découverte
5 +//
6 +// Chaque swipe (👍 droite / 👎 gauche) alimente un profil de préférences
7 +// par caractéristique : ville, quartier, taille, tranche de prix, meublé,
8 +// animaux, gestionnaire. Le score d'une annonce = moyenne des taux
9 +// d'appréciation lissés (Laplace) de ses caractéristiques, pondérée par la
10 +// confiance (nombre d'observations). Les annonces non vues sont classées
11 +// par score, avec une part d'exploration pour éviter la bulle de filtre.
12 +// Tout est persisté localement (UserDefaults) — aucune donnée ne sort.
13 +// -----------------------------------------------------------------------------
14 +import Foundation
15 +import Observation
16 +
17 +@MainActor
18 +@Observable
19 +final class RecoEngine {
20 + /// uids aimés, du plus récent au plus ancien (alimente « Mes coups de cœur »)
21 + private(set) var likedUIDs: [String] = []
22 + private(set) var passedUIDs: Set<String> = []
23 + /// caractéristique -> [nb de 👍, nb de 👎]
24 + private var featureCounts: [String: [Int]] = [:]
25 +
26 + var decisions: Int { likedUIDs.count + passedUIDs.count }
27 +
28 + private static let storeKey = "louka.reco.v1"
29 +
30 + init() { load() }
31 +
32 + // MARK: décisions
33 +
34 + func isSeen(_ uid: String) -> Bool {
35 + passedUIDs.contains(uid) || likedUIDs.contains(uid)
36 + }
37 +
38 + func record(_ listing: Listing, liked: Bool) {
39 + if liked {
40 + likedUIDs.removeAll { $0 == listing.uid }
41 + likedUIDs.insert(listing.uid, at: 0)
42 + } else {
43 + passedUIDs.insert(listing.uid)
44 + }
45 + for f in Self.features(of: listing) {
46 + var c = featureCounts[f] ?? [0, 0]
47 + c[liked ? 0 : 1] += 1
48 + featureCounts[f] = c
49 + }
50 + save()
51 + }
52 +
53 + /// annule la dernière décision sur cette annonce (bouton ↩)
54 + func undo(_ listing: Listing, wasLiked: Bool) {
55 + if wasLiked {
56 + likedUIDs.removeAll { $0 == listing.uid }
57 + } else {
58 + passedUIDs.remove(listing.uid)
59 + }
60 + for f in Self.features(of: listing) {
61 + var c = featureCounts[f] ?? [0, 0]
62 + c[wasLiked ? 0 : 1] = max(0, c[wasLiked ? 0 : 1] - 1)
63 + featureCounts[f] = c
64 + }
65 + save()
66 + }
67 +
68 + func unlike(_ uid: String) {
69 + likedUIDs.removeAll { $0 == uid }
70 + save()
71 + }
72 +
73 + func resetProfile() {
74 + likedUIDs = []
75 + passedUIDs = []
76 + featureCounts = [:]
77 + save()
78 + }
79 +
80 + // MARK: scoring
81 +
82 + /// score 0..1 — 0,5 = aucun signal
83 + func score(_ listing: Listing) -> Double {
84 + let feats = Self.features(of: listing)
85 + guard !feats.isEmpty else { return 0.5 }
86 + var num = 0.0, den = 0.0
87 + for f in feats {
88 + let c = featureCounts[f] ?? [0, 0]
89 + let likes = Double(c[0]), passes = Double(c[1])
90 + let rate = (likes + 1) / (likes + passes + 2) // lissage de Laplace
91 + let weight = 1 + log(1 + likes + passes) // confiance
92 + num += rate * weight
93 + den += weight
94 + }
95 + return num / den
96 + }
97 +
98 + /// classe les annonces non vues : meilleures d'abord, avec exploration
99 + /// (~1 carte sur 6 vient du reste du bassin pour continuer d'apprendre)
100 + func rank(_ listings: [Listing]) -> [Listing] {
101 + let unseen = listings.filter { !isSeen($0.uid) }
102 + guard decisions >= 3 else { return unseen.shuffled() }
103 + var scored = unseen
104 + .map { (listing: $0, s: score($0) + Double.random(in: 0..<0.04)) }
105 + .sorted { $0.s > $1.s }
106 + var out: [Listing] = []
107 + var explorePool = scored.count > 12 ? Array(scored.suffix(from: scored.count / 2)) : []
108 + var i = 0
109 + while !scored.isEmpty {
110 + i += 1
111 + if i % 6 == 0, !explorePool.isEmpty {
112 + let pick = explorePool.removeFirst()
113 + if let idx = scored.firstIndex(where: { $0.listing.uid == pick.listing.uid }) {
114 + scored.remove(at: idx)
115 + out.append(pick.listing)
116 + continue
117 + }
118 + }
119 + out.append(scored.removeFirst().listing)
120 + }
121 + return out
122 + }
123 +
124 + /// caractéristiques apprises d'une annonce
125 + static func features(of l: Listing) -> [String] {
126 + var f: [String] = []
127 + if !l.city.isEmpty { f.append("city:\(l.city)") }
128 + if !l.sector.isEmpty { f.append("sector:\(l.sector)") }
129 + if !l.unitType.isEmpty { f.append("type:\(l.unitType)") }
130 + if !l.source.isEmpty { f.append("source:\(l.source)") }
131 + if let p = l.price {
132 + f.append("price:\(Int(p / 300) * 300)") // tranches de 300 $
133 + }
134 + if let furn = l.furnished { f.append("furnished:\(furn)") }
135 + if let pets = l.pets { f.append("pets:\(pets)") }
136 + if l.details?.balcony == true { f.append("balcony") }
137 + if l.details?.parking?.available == true { f.append("parking") }
138 + return f
139 + }
140 +
141 + /// aperçu du profil appris (affiché dans Découverte) : top caractéristiques aimées
142 + func topTastes(_ n: Int = 3) -> [String] {
143 + featureCounts
144 + .filter { $0.value[0] >= 2 && $0.value[0] > $0.value[1] }
145 + .sorted { ($0.value[0] - $0.value[1]) > ($1.value[0] - $1.value[1]) }
146 + .prefix(n)
147 + .compactMap { key, _ in
148 + let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
149 + guard parts.count == 2 else { return key == "balcony" ? "Balcon" : (key == "parking" ? "Stationnement" : key) }
150 + switch parts[0] {
151 + case "city": return parts[1]
152 + case "sector": return parts[1]
153 + case "type": return parts[1]
154 + case "price": return "~\(parts[1]) $"
155 + case "furnished": return parts[1] == "true" ? "Meublé" : "Non meublé"
156 + case "pets": return "Animaux \(parts[1])"
157 + case "source": return nil // trop interne pour l'affichage
158 + default: return nil
159 + }
160 + }
161 + }
162 +
163 + // MARK: persistance
164 +
165 + private struct Snapshot: Codable {
166 + var liked: [String]
167 + var passed: [String]
168 + var counts: [String: [Int]]
169 + }
170 +
171 + private func save() {
172 + let snap = Snapshot(liked: likedUIDs, passed: Array(passedUIDs), counts: featureCounts)
173 + if let data = try? JSONEncoder().encode(snap) {
174 + UserDefaults.standard.set(data, forKey: Self.storeKey)
175 + }
176 + }
177 +
178 + private func load() {
179 + guard let data = UserDefaults.standard.data(forKey: Self.storeKey),
180 + let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return }
181 + likedUIDs = snap.liked
182 + passedUIDs = Set(snap.passed)
183 + featureCounts = snap.counts
184 + }
185 +}
added LouKa/Theme.swift +270 −0
@@ -0,0 +1,270 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Theme.swift : système de design « éditorial sharp » porté en SwiftUI
5 +// · palette papier / encre / vert profond / lime électrique (styles.css)
6 +// · typographie de marque : Space Grotesk (display) + JetBrains Mono (micro)
7 +// · signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
8 +// · thème clair UNIQUEMENT (comme le site) — voir .preferredColorScheme
9 +// · formatage fr-CA (prix, dates, distances)
10 +// -----------------------------------------------------------------------------
11 +import SwiftUI
12 +import UIKit
13 +
14 +extension Color {
15 + init(hex: UInt32) {
16 + self.init(
17 + .sRGB,
18 + red: Double((hex >> 16) & 0xFF) / 255,
19 + green: Double((hex >> 8) & 0xFF) / 255,
20 + blue: Double(hex & 0xFF) / 255
21 + )
22 + }
23 +}
24 +
25 +enum LK {
26 + static let paper = Color(hex: 0xF5F3EE)
27 + static let surface = Color.white
28 + static let surface2 = Color(hex: 0xFAF9F5)
29 + static let ink = Color(hex: 0x141814)
30 + static let ink2 = Color(hex: 0x4D5551)
31 + static let ink3 = Color(hex: 0x8B928C)
32 + static let green = Color(hex: 0x1C5C41)
33 + static let greenDeep = Color(hex: 0x123F2E)
34 + static let lime = Color(hex: 0xD9F26B)
35 + static let limeSoft = Color(hex: 0xF0F9D2)
36 + static let amber = Color(hex: 0xE8A33D)
37 + static let amberSoft = Color(hex: 0xFDF3E2)
38 + static let danger = Color(hex: 0xB3423A)
39 + static let line = Color(hex: 0x141814).opacity(0.14)
40 +}
41 +
42 +// MARK: - Typographie de marque
43 +
44 +/// Polices embarquées (Fonts/) avec repli système si l'enregistrement échoue.
45 +enum LKFont {
46 + private static let hasGrotesk = UIFont(name: "SpaceGrotesk-Bold", size: 12) != nil
47 + private static let hasMono = UIFont(name: "JetBrainsMono-Regular", size: 12) != nil
48 +
49 + /// Titres & prix — Space Grotesk (géométrique, signature du site)
50 + static func display(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {
51 + guard hasGrotesk else { return .system(size: size, weight: weight) }
52 + switch weight {
53 + case .bold, .heavy, .black: return .custom("SpaceGrotesk-Bold", size: size)
54 + case .medium, .semibold: return .custom("SpaceGrotesk-Medium", size: size)
55 + default: return .custom("SpaceGrotesk-Regular", size: size)
56 + }
57 + }
58 +
59 + /// Micro-étiquettes, chiffres, tags — JetBrains Mono
60 + static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
61 + guard hasMono else { return .system(size: size, weight: weight, design: .monospaced) }
62 + switch weight {
63 + case .bold, .heavy, .black: return .custom("JetBrainsMono-Bold", size: size)
64 + case .medium, .semibold: return .custom("JetBrainsMono-Medium", size: size)
65 + default: return .custom("JetBrainsMono-Regular", size: size)
66 + }
67 + }
68 +}
69 +
70 +// MARK: - Carte à ombre décalée (signature visuelle)
71 +
72 +struct LKCardModifier: ViewModifier {
73 + var radius: CGFloat = 10
74 + var offset: CGFloat = 5
75 + var borderWidth: CGFloat = 1.8
76 +
77 + func body(content: Content) -> some View {
78 + content
79 + .background(LK.surface)
80 + .clipShape(RoundedRectangle(cornerRadius: radius))
81 + .overlay(RoundedRectangle(cornerRadius: radius).stroke(LK.ink, lineWidth: borderWidth))
82 + .background(
83 + RoundedRectangle(cornerRadius: radius)
84 + .fill(LK.ink)
85 + .offset(x: offset, y: offset)
86 + )
87 + }
88 +}
89 +
90 +extension View {
91 + func lkCard(radius: CGFloat = 10, offset: CGFloat = 5, borderWidth: CGFloat = 1.8) -> some View {
92 + modifier(LKCardModifier(radius: radius, offset: offset, borderWidth: borderWidth))
93 + }
94 +}
95 +
96 +// MARK: - Micro-composants
97 +
98 +/// Étiquette mono majuscule avec tiret vert — le « kicker » du site web
99 +struct Kicker: View {
100 + let text: String
101 +
102 + var body: some View {
103 + HStack(spacing: 8) {
104 + Rectangle().fill(LK.green).frame(width: 22, height: 2)
105 + Text(text.uppercased())
106 + .font(LKFont.mono(11, .medium))
107 + .kerning(1.4)
108 + .foregroundStyle(LK.green)
109 + }
110 + }
111 +}
112 +
113 +/// Pastille type de logement (encre sur lime) — ex. « 4½ »
114 +struct UnitTypeBadge: View {
115 + let type: String
116 +
117 + var body: some View {
118 + Text(type)
119 + .font(LKFont.mono(12, .bold))
120 + .padding(.horizontal, 8)
121 + .padding(.vertical, 4)
122 + .background(LK.ink)
123 + .foregroundStyle(LK.lime)
124 + .clipShape(RoundedRectangle(cornerRadius: 5))
125 + }
126 +}
127 +
128 +/// Puce filtre / commodité
129 +struct Chip: View {
130 + let label: String
131 + var selected = false
132 + var action: (() -> Void)?
133 +
134 + var body: some View {
135 + let core = Text(label)
136 + .font(LKFont.display(13.5, .medium))
137 + .padding(.horizontal, 14)
138 + .padding(.vertical, 8)
139 + .background(selected ? LK.ink : LK.surface)
140 + .foregroundStyle(selected ? LK.lime : LK.ink)
141 + .clipShape(Capsule())
142 + .overlay(Capsule().stroke(selected ? LK.ink : LK.ink.opacity(0.35), lineWidth: 1.5))
143 + if let action {
144 + Button(action: action) { core }.buttonStyle(.plain)
145 + } else {
146 + core
147 + }
148 + }
149 +}
150 +
151 +/// Ruban défilant encre/lime — équivalent du « ticker » du site
152 +struct TickerBar: View {
153 + let text: String
154 + @State private var segWidth: CGFloat = 0
155 +
156 + private var segment: some View {
157 + Text("\(text) ◆ ")
158 + .font(LKFont.mono(11, .medium))
159 + .kerning(1.2)
160 + .foregroundStyle(LK.lime)
161 + .fixedSize()
162 + }
163 +
164 + var body: some View {
165 + ZStack(alignment: .leading) {
166 + // gabarit invisible : mesure la largeur d'un segment
167 + segment
168 + .hidden()
169 + .background(GeometryReader { g in
170 + Color.clear.onAppear { segWidth = g.size.width }
171 + })
172 + if segWidth > 0 {
173 + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in
174 + let t = ctx.date.timeIntervalSinceReferenceDate
175 + let phase = CGFloat((t * 28).truncatingRemainder(dividingBy: Double(segWidth)))
176 + HStack(spacing: 0) {
177 + segment
178 + segment
179 + segment
180 + segment
181 + }
182 + .offset(x: -phase)
183 + }
184 + }
185 + }
186 + .frame(maxWidth: .infinity, alignment: .leading)
187 + .padding(.vertical, 8)
188 + .background(LK.ink)
189 + .clipShape(RoundedRectangle(cornerRadius: 7))
190 + .overlay(RoundedRectangle(cornerRadius: 7).stroke(LK.ink, lineWidth: 1.5))
191 + }
192 +}
193 +
194 +/// Marque « Lou·Ka » — le « Ka » en encre sur lime, légèrement incliné
195 +struct BrandMark: View {
196 + var size: CGFloat = 30
197 +
198 + var body: some View {
199 + HStack(alignment: .center, spacing: 3) {
200 + Text("Lou")
201 + .font(LKFont.display(size, .bold))
202 + .kerning(-1)
203 + .foregroundStyle(LK.ink)
204 + Text("Ka")
205 + .font(LKFont.display(size * 0.86, .bold))
206 + .kerning(-1)
207 + .padding(.horizontal, size * 0.24)
208 + .padding(.vertical, 2)
209 + .background(LK.ink)
210 + .foregroundStyle(LK.lime)
211 + .clipShape(RoundedRectangle(cornerRadius: 6))
212 + .rotationEffect(.degrees(-2))
213 + }
214 + }
215 +}
216 +
217 +// MARK: - Formatage fr-CA
218 +
219 +enum Fmt {
220 + static let priceFormatter: NumberFormatter = {
221 + let f = NumberFormatter()
222 + f.locale = Locale(identifier: "fr_CA")
223 + f.numberStyle = .decimal
224 + f.maximumFractionDigits = 0
225 + return f
226 + }()
227 +
228 + /// 1250 → « 1 250 $ » ; nil → étiquette source ou « Prix sur demande »
229 + static func price(_ p: Double?, label: String = "") -> String {
230 + guard let p else { return label.isEmpty ? "Prix sur demande" : label }
231 + return (priceFormatter.string(from: NSNumber(value: p)) ?? "\(Int(p))") + " $"
232 + }
233 +
234 + /// Entier avec séparateur fr-CA (ex. 9 097)
235 + static func int(_ n: Int) -> String {
236 + priceFormatter.string(from: NSNumber(value: n)) ?? "\(n)"
237 + }
238 +
239 + /// "now" → « Maintenant », "2026-12-01" → « 1ᵉʳ décembre 2026 »
240 + static func availability(_ iso: String?) -> String? {
241 + guard let iso, !iso.isEmpty else { return nil }
242 + if iso == "now" { return "Maintenant" }
243 + let parts = iso.split(separator: "-").compactMap { Int($0) }
244 + guard parts.count == 3 else { return nil }
245 + var comps = DateComponents()
246 + (comps.year, comps.month, comps.day) = (parts[0], parts[1], parts[2])
247 + guard let date = Calendar.current.date(from: comps) else { return nil }
248 + let f = DateFormatter()
249 + f.locale = Locale(identifier: "fr_CA")
250 + f.dateFormat = "d MMMM yyyy"
251 + var txt = f.string(from: date)
252 + if txt.hasPrefix("1 ") { txt = "1ᵉʳ " + txt.dropFirst(2) }
253 + return txt
254 + }
255 +
256 + /// 250 → « 250 m », 1240 → « 1,2 km »
257 + static func dist(_ m: Double) -> String {
258 + if m < 1000 { return "\(Int((m / 10).rounded()) * 10) m" }
259 + return String(format: "%.1f km", m / 1000).replacingOccurrences(of: ".", with: ",")
260 + }
261 +
262 + /// Horodatage Unix → « il y a 2 h »
263 + static func relative(_ ts: Double?) -> String? {
264 + guard let ts, ts > 0 else { return nil }
265 + let f = RelativeDateTimeFormatter()
266 + f.locale = Locale(identifier: "fr_CA")
267 + f.unitsStyle = .short
268 + return f.localizedString(for: Date(timeIntervalSince1970: ts), relativeTo: Date())
269 + }
270 +}
added LouKa/Views/DiscoverView.swift +486 −0
@@ -0,0 +1,486 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// DiscoverView.swift : mode Découverte — une annonce plein écran à la fois,
5 +// swipe à droite = coup de cœur 💚, à gauche = on passe ✕.
6 +// Chaque geste nourrit le RecoEngine qui reclasse le reste du bassin
7 +// pour présenter des logements de plus en plus proches de vos goûts.
8 +// -----------------------------------------------------------------------------
9 +import SwiftUI
10 +
11 +struct DiscoverView: View {
12 + @Environment(AppModel.self) private var model
13 + @Environment(RecoEngine.self) private var reco
14 +
15 + @State private var deck: [Listing] = []
16 + @State private var index = 0
17 + @State private var photoIndex = 0
18 + @State private var drag: CGSize = .zero
19 + @State private var loading = true
20 + @State private var showLikes = false
21 + @State private var detail: Listing?
22 + @State private var lastDecision: (listing: Listing, liked: Bool)?
23 + @State private var decisionsSinceRank = 0
24 +
25 + private var current: Listing? { deck.indices.contains(index) ? deck[index] : nil }
26 + private var next: Listing? { deck.indices.contains(index + 1) ? deck[index + 1] : nil }
27 +
28 + var body: some View {
29 + ZStack {
30 + LK.paper.ignoresSafeArea()
31 + VStack(spacing: 12) {
32 + header
33 + ZStack {
34 + if loading {
35 + ProgressView("Préparation de votre pile…")
36 + .tint(LK.green)
37 + .foregroundStyle(LK.ink2)
38 + .frame(maxWidth: .infinity, maxHeight: .infinity)
39 + } else if let listing = current {
40 + if let next {
41 + card(next, isTop: false)
42 + .scaleEffect(0.94 + min(abs(drag.width) / 1200, 0.06))
43 + .offset(y: 12)
44 + }
45 + card(listing, isTop: true)
46 + } else {
47 + emptyState
48 + }
49 + }
50 + .frame(maxHeight: .infinity)
51 + actionBar
52 + }
53 + .padding(.horizontal, 16)
54 + .padding(.top, 8)
55 + .padding(.bottom, 10)
56 + }
57 + .task { await loadDeck() }
58 + .sheet(isPresented: $showLikes) { LikesSheet() }
59 + .sheet(item: $detail) { l in
60 + NavigationStack {
61 + ListingDetailView(preview: l)
62 + .toolbar {
63 + ToolbarItem(placement: .topBarTrailing) {
64 + Button("Fermer") { detail = nil }
65 + }
66 + }
67 + }
68 + }
69 + }
70 +
71 + // MARK: données
72 +
73 + private func loadDeck() async {
74 + guard deck.isEmpty else { return }
75 + loading = true
76 + var f = ListingFilters()
77 + f.city = ""
78 + let r = try? await API.listings(f, limit: 800)
79 + let pool = (r?.listings ?? []).filter { !$0.images.isEmpty }
80 + deck = reco.rank(pool)
81 + index = 0
82 + loading = false
83 + }
84 +
85 + private func decide(liked: Bool) {
86 + guard let listing = current else { return }
87 + reco.record(listing, liked: liked)
88 + lastDecision = (listing, liked)
89 + decisionsSinceRank += 1
90 + withAnimation(.spring(duration: 0.35)) {
91 + drag = CGSize(width: liked ? 640 : -640, height: -40)
92 + }
93 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
94 + index += 1
95 + photoIndex = 0
96 + drag = .zero
97 + // adaptation en continu : reclasser le reste de la pile
98 + if decisionsSinceRank >= 8 {
99 + decisionsSinceRank = 0
100 + let rest = Array(deck.suffix(from: min(index, deck.count)))
101 + deck = Array(deck.prefix(min(index, deck.count))) + reco.rank(rest)
102 + }
103 + }
104 + }
105 +
106 + private func undo() {
107 + guard let last = lastDecision, index > 0 else { return }
108 + reco.undo(last.listing, wasLiked: last.liked)
109 + lastDecision = nil
110 + withAnimation(.spring(duration: 0.3)) {
111 + index -= 1
112 + photoIndex = 0
113 + }
114 + }
115 +
116 + // MARK: en-tête
117 +
118 + private var header: some View {
119 + HStack(alignment: .center) {
120 + VStack(alignment: .leading, spacing: 3) {
121 + Kicker(text: "Découverte")
122 + let tastes = reco.topTastes()
123 + Text(tastes.isEmpty
124 + ? "Swipez — l'algorithme apprend vos goûts"
125 + : "Vos goûts : \(tastes.joined(separator: " · "))")
126 + .font(LKFont.mono(10, .medium))
127 + .foregroundStyle(LK.ink3)
128 + .lineLimit(1)
129 + }
130 + Spacer()
131 + if !loading, current != nil {
132 + Text("\(min(index + 1, deck.count))/\(deck.count)")
133 + .font(LKFont.mono(11, .medium))
134 + .foregroundStyle(LK.ink3)
135 + }
136 + Button {
137 + showLikes = true
138 + } label: {
139 + HStack(spacing: 5) {
140 + Image(systemName: "heart.fill")
141 + .font(.system(size: 13))
142 + Text("\(reco.likedUIDs.count)")
143 + .font(LKFont.mono(12, .bold))
144 + }
145 + .padding(.horizontal, 11)
146 + .padding(.vertical, 7)
147 + .background(LK.ink)
148 + .foregroundStyle(LK.lime)
149 + .clipShape(Capsule())
150 + }
151 + .buttonStyle(.plain)
152 + }
153 + }
154 +
155 + // MARK: carte plein écran
156 +
157 + @ViewBuilder
158 + private func card(_ listing: Listing, isTop: Bool) -> some View {
159 + GeometryReader { geo in
160 + ZStack(alignment: .bottom) {
161 + photo(listing, size: geo.size, isTop: isTop)
162 + overlayInfo(listing)
163 + if isTop { stamps }
164 + }
165 + .frame(width: geo.size.width, height: geo.size.height)
166 + .background(LK.ink)
167 + .clipShape(RoundedRectangle(cornerRadius: 18))
168 + .overlay(RoundedRectangle(cornerRadius: 18).stroke(LK.ink, lineWidth: 2))
169 + .background(
170 + RoundedRectangle(cornerRadius: 18).fill(LK.ink).offset(x: 6, y: 6)
171 + )
172 + .padding(.trailing, 6)
173 + .padding(.bottom, 6)
174 + .offset(isTop ? drag : .zero)
175 + .rotationEffect(isTop ? .degrees(Double(drag.width) / 22) : .zero, anchor: .bottom)
176 + .gesture(isTop ? dragGesture : nil)
177 + .onTapGesture { location in
178 + guard isTop else { return }
179 + let w = geo.size.width
180 + if location.y < geo.size.height * 0.62 {
181 + if location.x > w * 0.6 {
182 + photoIndex = (photoIndex + 1) % max(listing.images.count, 1)
183 + } else if location.x < w * 0.4 {
184 + photoIndex = (photoIndex - 1 + max(listing.images.count, 1)) % max(listing.images.count, 1)
185 + } else {
186 + detail = listing
187 + }
188 + } else {
189 + detail = listing
190 + }
191 + }
192 + }
193 + }
194 +
195 + private var dragGesture: some Gesture {
196 + DragGesture()
197 + .onChanged { drag = $0.translation }
198 + .onEnded { value in
199 + if value.translation.width > 110 {
200 + decide(liked: true)
201 + } else if value.translation.width < -110 {
202 + decide(liked: false)
203 + } else {
204 + withAnimation(.spring(duration: 0.3)) { drag = .zero }
205 + }
206 + }
207 + }
208 +
209 + private func photo(_ listing: Listing, size: CGSize, isTop: Bool) -> some View {
210 + let idx = isTop ? min(photoIndex, listing.images.count - 1) : 0
211 + return ZStack(alignment: .top) {
212 + AsyncImage(url: URL(string: listing.images[max(0, idx)])) { phase in
213 + switch phase {
214 + case .success(let image):
215 + image.resizable().aspectRatio(contentMode: .fill)
216 + case .failure:
217 + ZStack {
218 + LK.limeSoft
219 + Image(systemName: "photo")
220 + .font(.system(size: 40))
221 + .foregroundStyle(LK.green.opacity(0.5))
222 + }
223 + default:
224 + ZStack {
225 + LK.surface2
226 + ProgressView().tint(LK.green)
227 + }
228 + }
229 + }
230 + .frame(width: size.width, height: size.height)
231 + .clipped()
232 +
233 + // indicateur de photos façon « stories »
234 + if isTop, listing.images.count > 1 {
235 + HStack(spacing: 3) {
236 + ForEach(0..<min(listing.images.count, 12), id: \.self) { i in
237 + Capsule()
238 + .fill(i == idx ? LK.lime : .white.opacity(0.45))
239 + .frame(height: 3)
240 + }
241 + }
242 + .padding(.horizontal, 14)
243 + .padding(.top, 12)
244 + }
245 + }
246 + }
247 +
248 + private func overlayInfo(_ listing: Listing) -> some View {
249 + VStack(alignment: .leading, spacing: 7) {
250 + HStack(alignment: .firstTextBaseline, spacing: 6) {
251 + Text(Fmt.price(listing.price, label: listing.priceLabel))
252 + .font(LKFont.display(30, .bold))
253 + .foregroundStyle(LK.lime)
254 + if listing.price != nil {
255 + Text("/ mois")
256 + .font(LKFont.mono(11))
257 + .foregroundStyle(.white.opacity(0.75))
258 + }
259 + Spacer()
260 + if !listing.unitType.isEmpty {
261 + Text(listing.unitType)
262 + .font(LKFont.mono(13, .bold))
263 + .padding(.horizontal, 9)
264 + .padding(.vertical, 5)
265 + .background(LK.lime)
266 + .foregroundStyle(LK.ink)
267 + .clipShape(RoundedRectangle(cornerRadius: 6))
268 + }
269 + }
270 + Text(listing.title.isEmpty ? listing.address : listing.title)
271 + .font(LKFont.display(18, .medium))
272 + .foregroundStyle(.white)
273 + .lineLimit(2)
274 + HStack(spacing: 10) {
275 + label(icon: "mappin", text: [listing.sector, listing.city]
276 + .filter { !$0.isEmpty }.joined(separator: " · "))
277 + if let dispo = Fmt.availability(listing.availabilityDate) {
278 + label(icon: "calendar", text: dispo)
279 + }
280 + if let area = listing.areaSqft {
281 + label(icon: "ruler", text: "\(Int(area)) pi²")
282 + }
283 + }
284 + Text("Toucher pour la fiche complète")
285 + .font(LKFont.mono(9, .medium))
286 + .foregroundStyle(.white.opacity(0.55))
287 + .padding(.top, 2)
288 + }
289 + .padding(18)
290 + .frame(maxWidth: .infinity, alignment: .leading)
291 + .background(
292 + LinearGradient(
293 + colors: [.clear, .black.opacity(0.55), .black.opacity(0.88)],
294 + startPoint: .top, endPoint: .bottom
295 + )
296 + )
297 + }
298 +
299 + private func label(icon: String, text: String) -> some View {
300 + HStack(spacing: 4) {
301 + Image(systemName: icon).font(.system(size: 10.5))
302 + Text(text).font(LKFont.mono(10.5, .medium)).lineLimit(1)
303 + }
304 + .foregroundStyle(.white.opacity(0.85))
305 + }
306 +
307 + /// tampons LIKE / PASSE pendant le glissement
308 + private var stamps: some View {
309 + ZStack(alignment: .top) {
310 + HStack {
311 + stamp(text: "COUP DE 🧡", color: LK.lime, rotation: -12)
312 + .opacity(min(Double(drag.width) / 90, 1))
313 + Spacer()
314 + stamp(text: "ON PASSE", color: LK.danger, rotation: 12)
315 + .opacity(min(Double(-drag.width) / 90, 1))
316 + }
317 + .padding(26)
318 + }
319 + .frame(maxHeight: .infinity, alignment: .top)
320 + .allowsHitTesting(false)
321 + }
322 +
323 + private func stamp(text: String, color: Color, rotation: Double) -> some View {
324 + Text(text)
325 + .font(LKFont.display(24, .bold))
326 + .padding(.horizontal, 14)
327 + .padding(.vertical, 8)
328 + .foregroundStyle(color)
329 + .overlay(RoundedRectangle(cornerRadius: 8).stroke(color, lineWidth: 3.5))
330 + .rotationEffect(.degrees(rotation))
331 + }
332 +
333 + // MARK: barre d'actions
334 +
335 + private var actionBar: some View {
336 + HStack(spacing: 26) {
337 + actionButton(icon: "xmark", size: 60, bg: LK.surface, fg: LK.danger) {
338 + decide(liked: false)
339 + }
340 + actionButton(icon: "arrow.uturn.backward", size: 44, bg: LK.surface, fg: LK.ink2) {
341 + undo()
342 + }
343 + .opacity(lastDecision == nil ? 0.35 : 1)
344 + actionButton(icon: "heart.fill", size: 60, bg: LK.ink, fg: LK.lime) {
345 + decide(liked: true)
346 + }
347 + }
348 + .frame(maxWidth: .infinity)
349 + .padding(.top, 2)
350 + .opacity(current == nil ? 0.3 : 1)
351 + .disabled(current == nil)
352 + }
353 +
354 + private func actionButton(icon: String, size: CGFloat, bg: Color, fg: Color,
355 + action: @escaping () -> Void) -> some View {
356 + Button(action: action) {
357 + Image(systemName: icon)
358 + .font(.system(size: size * 0.38, weight: .bold))
359 + .frame(width: size, height: size)
360 + .background(bg)
361 + .foregroundStyle(fg)
362 + .clipShape(Circle())
363 + .overlay(Circle().stroke(LK.ink, lineWidth: 1.8))
364 + .background(Circle().fill(LK.ink).offset(x: 3, y: 3))
365 + }
366 + .buttonStyle(.plain)
367 + }
368 +
369 + // MARK: fin de pile
370 +
371 + private var emptyState: some View {
372 + VStack(spacing: 14) {
373 + Text("🏁")
374 + .font(.system(size: 52))
375 + Text("Vous avez tout vu !")
376 + .font(LKFont.display(22, .bold))
377 + Text("\(reco.likedUIDs.count) coups de cœur retenus. Revenez plus tard —\nde nouvelles annonces arrivent chaque heure.")
378 + .font(.system(size: 14))
379 + .foregroundStyle(LK.ink2)
380 + .multilineTextAlignment(.center)
381 + Button {
382 + reco.resetProfile()
383 + deck = []
384 + Task { await loadDeck() }
385 + } label: {
386 + Text("Recommencer à zéro")
387 + .font(LKFont.display(15, .bold))
388 + .padding(.horizontal, 20)
389 + .padding(.vertical, 12)
390 + .background(LK.ink)
391 + .foregroundStyle(LK.lime)
392 + .clipShape(Capsule())
393 + }
394 + .buttonStyle(.plain)
395 + .padding(.top, 6)
396 + }
397 + .frame(maxWidth: .infinity, maxHeight: .infinity)
398 + }
399 +}
400 +
401 +// MARK: - Coups de cœur
402 +
403 +struct LikesSheet: View {
404 + @Environment(AppModel.self) private var model
405 + @Environment(RecoEngine.self) private var reco
406 + @Environment(\.dismiss) private var dismiss
407 + @State private var likes: [Listing] = []
408 + @State private var loading = true
409 + @State private var detail: Listing?
410 +
411 + var body: some View {
412 + NavigationStack {
413 + ScrollView {
414 + LazyVStack(alignment: .leading, spacing: 14) {
415 + if loading {
416 + ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
417 + } else if likes.isEmpty {
418 + VStack(spacing: 8) {
419 + Image(systemName: "heart").font(.system(size: 30))
420 + Text("Aucun coup de cœur pour l'instant.\nSwipez à droite dans Découverte !")
421 + .multilineTextAlignment(.center)
422 + .font(.system(size: 14))
423 + }
424 + .foregroundStyle(LK.ink3)
425 + .frame(maxWidth: .infinity)
426 + .padding(.vertical, 50)
427 + } else {
428 + ForEach(likes) { l in
429 + Button {
430 + detail = l
431 + } label: {
432 + ListingCardView(listing: l)
433 + }
434 + .buttonStyle(.plain)
435 + .padding(.trailing, 6)
436 + .padding(.bottom, 6)
437 + .contextMenu {
438 + Button(role: .destructive) {
439 + reco.unlike(l.uid)
440 + likes.removeAll { $0.uid == l.uid }
441 + } label: {
442 + Label("Retirer des coups de cœur", systemImage: "heart.slash")
443 + }
444 + }
445 + }
446 + }
447 + }
448 + .padding(16)
449 + }
450 + .background(LK.paper)
451 + .navigationTitle("Mes coups de cœur")
452 + .navigationBarTitleDisplayMode(.inline)
453 + .toolbar {
454 + ToolbarItem(placement: .topBarTrailing) {
455 + Button("Fermer") { dismiss() }
456 + .font(LKFont.display(15, .bold))
457 + }
458 + }
459 + .sheet(item: $detail) { l in
460 + NavigationStack {
461 + ListingDetailView(preview: l)
462 + .toolbar {
463 + ToolbarItem(placement: .topBarTrailing) {
464 + Button("Fermer") { detail = nil }
465 + }
466 + }
467 + }
468 + }
469 + .task { await load() }
470 + }
471 + .preferredColorScheme(.light)
472 + }
473 +
474 + private func load() async {
475 + loading = true
476 + var out: [Listing] = []
477 + // les fiches aimées, dans l'ordre (récent d'abord) — 30 max affichées
478 + for uid in reco.likedUIDs.prefix(30) {
479 + if let l = try? await API.listing(uid: uid) {
480 + out.append(l)
481 + }
482 + }
483 + likes = out
484 + loading = false
485 + }
486 +}
added LouKa/Views/HomeView.swift +494 −0
@@ -0,0 +1,494 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// HomeView.swift : accueil — marque, héro éditorial, ticker animé, recherche,
5 +// filtres, liste d'annonces
6 +// -----------------------------------------------------------------------------
7 +import SwiftUI
8 +
9 +private let UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Studio", "Loft"]
10 +
11 +struct HomeView: View {
12 + @Environment(AppModel.self) private var model
13 + @State private var filters = ListingFilters()
14 + @State private var listings: [Listing]?
15 + @State private var total = 0
16 + @State private var errorMessage: String?
17 + @State private var showFilters = false
18 +
19 + var body: some View {
20 + NavigationStack {
21 + ScrollView {
22 + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: []) {
23 + brandHeader
24 + tickerBar
25 + searchBar
26 + typeChips
27 + activePills
28 + resultsHeader
29 + content
30 + }
31 + .padding(.horizontal, 16)
32 + .padding(.bottom, 24)
33 + }
34 + .background(LK.paper)
35 + .scrollDismissesKeyboard(.immediately)
36 + .toolbar(.hidden, for: .navigationBar)
37 + .navigationDestination(for: Listing.self) { ListingDetailView(preview: $0) }
38 + .sheet(isPresented: $showFilters) {
39 + FiltersSheet(filters: $filters)
40 + .presentationDetents([.medium, .large])
41 + }
42 + .task(id: filters) { await load() }
43 + .refreshable {
44 + await model.loadGlobals(force: true)
45 + await load(debounce: false)
46 + }
47 + }
48 + }
49 +
50 + // MARK: chargement
51 +
52 + private func load(debounce: Bool = true) async {
53 + errorMessage = nil
54 + if debounce {
55 + try? await Task.sleep(for: .milliseconds(250))
56 + if Task.isCancelled { return }
57 + }
58 + do {
59 + let r = try await API.listings(filters)
60 + listings = r.listings
61 + total = r.total
62 + } catch is CancellationError {
63 + } catch {
64 + if (error as? URLError)?.code == .cancelled { return }
65 + errorMessage = error.localizedDescription
66 + }
67 + }
68 +
69 + // MARK: sections
70 +
71 + private var brandHeader: some View {
72 + VStack(alignment: .leading, spacing: 12) {
73 + HStack(alignment: .center) {
74 + BrandMark(size: 32)
75 + Spacer()
76 + Text("QC · LÉVIS · MTL · +")
77 + .font(LKFont.mono(10, .medium))
78 + .kerning(0.8)
79 + .foregroundStyle(LK.ink3)
80 + }
81 + Text("Tous les logements à louer du Québec. Un seul endroit.")
82 + .font(LKFont.display(23, .bold))
83 + .kerning(-0.5)
84 + .foregroundStyle(LK.ink)
85 + .lineSpacing(1)
86 + Kicker(text: "Agrégateur indépendant — \(Fmt.int(model.stats?.sources ?? 190)) gestionnaires")
87 + }
88 + .padding(.top, 8)
89 + }
90 +
91 + private var tickerBar: some View {
92 + Group {
93 + if let s = model.stats {
94 + TickerBar(text: "\(Fmt.int(s.total)) ANNONCES ◆ \(s.sources) SOURCES ◆ LOYER MOYEN \(Fmt.price(s.avgPrice.map { $0.rounded() })) ◆ MISE À JOUR CONTINUE")
95 + }
96 + }
97 + }
98 +
99 + private var searchBar: some View {
100 + HStack(spacing: 12) {
101 + HStack(spacing: 8) {
102 + Image(systemName: "magnifyingglass")
103 + .font(.system(size: 15, weight: .semibold))
104 + .foregroundStyle(LK.ink2)
105 + TextField("Rue, quartier, ville…", text: Binding(
106 + get: { filters.q },
107 + set: { filters.q = $0 }
108 + ))
109 + .autocorrectionDisabled()
110 + .font(.system(size: 15))
111 + .foregroundStyle(LK.ink)
112 + .tint(LK.green)
113 + if !filters.q.isEmpty {
114 + Button {
115 + filters.q = ""
116 + } label: {
117 + Image(systemName: "xmark.circle.fill").foregroundStyle(LK.ink3)
118 + }
119 + }
120 + }
121 + .padding(.horizontal, 13)
122 + .padding(.vertical, 12)
123 + .lkCard(radius: 8, offset: 4)
124 +
125 + Button {
126 + showFilters = true
127 + } label: {
128 + HStack(spacing: 5) {
129 + Image(systemName: "slider.horizontal.3")
130 + .font(.system(size: 15, weight: .semibold))
131 + if filters.activeCount > 0 {
132 + Text("\(filters.activeCount)")
133 + .font(LKFont.mono(12, .bold))
134 + }
135 + }
136 + .padding(.horizontal, 14)
137 + .padding(.vertical, 13)
138 + .background(filters.activeCount > 0 ? LK.ink : LK.surface)
139 + .foregroundStyle(filters.activeCount > 0 ? LK.lime : LK.ink)
140 + .clipShape(RoundedRectangle(cornerRadius: 8))
141 + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.ink, lineWidth: 1.8))
142 + .background(RoundedRectangle(cornerRadius: 8).fill(LK.ink).offset(x: 4, y: 4))
143 + }
144 + .buttonStyle(.plain)
145 + }
146 + .padding(.trailing, 4)
147 + }
148 +
149 + private var typeChips: some View {
150 + ScrollView(.horizontal, showsIndicators: false) {
151 + HStack(spacing: 8) {
152 + Chip(label: "Tous", selected: filters.unitType.isEmpty) {
153 + filters.unitType = ""
154 + }
155 + ForEach(UNIT_TYPES, id: \.self) { t in
156 + Chip(label: t, selected: filters.unitType == t) {
157 + filters.unitType = filters.unitType == t ? "" : t
158 + }
159 + }
160 + Rectangle().fill(LK.line).frame(width: 1.5, height: 22)
161 + Chip(label: "⚡ Dispo maintenant", selected: filters.dispoDays == 0) {
162 + filters.dispoDays = filters.dispoDays == 0 ? nil : 0
163 + }
164 + Chip(label: "🐾 Animaux ok", selected: filters.petsOk) {
165 + filters.petsOk.toggle()
166 + }
167 + Chip(label: "🛋 Meublé", selected: filters.furnished == true) {
168 + filters.furnished = filters.furnished == true ? nil : true
169 + }
170 + }
171 + .padding(.vertical, 2)
172 + }
173 + }
174 +
175 + /// pastilles « filtres actifs » — un tap retire le filtre
176 + @ViewBuilder
177 + private var activePills: some View {
178 + let pills = pillItems
179 + if !pills.isEmpty {
180 + ScrollView(.horizontal, showsIndicators: false) {
181 + HStack(spacing: 7) {
182 + ForEach(pills, id: \.0) { label, clear in
183 + Button {
184 + clear()
185 + } label: {
186 + HStack(spacing: 5) {
187 + Text(label)
188 + Image(systemName: "xmark")
189 + .font(.system(size: 8, weight: .bold))
190 + .opacity(0.6)
191 + }
192 + .font(LKFont.mono(11, .medium))
193 + .padding(.horizontal, 11)
194 + .padding(.vertical, 6)
195 + .background(LK.limeSoft)
196 + .foregroundStyle(LK.greenDeep)
197 + .clipShape(Capsule())
198 + .overlay(Capsule().stroke(LK.green.opacity(0.45), lineWidth: 1.2))
199 + }
200 + .buttonStyle(.plain)
201 + }
202 + }
203 + }
204 + }
205 + }
206 +
207 + private var pillItems: [(String, () -> Void)] {
208 + var out: [(String, () -> Void)] = []
209 + if !filters.q.isEmpty { out.append((\(filters.q) »", { filters.q = "" })) }
210 + if !filters.city.isEmpty { out.append((filters.city, { filters.city = ""; filters.sector = "" })) }
211 + if !filters.sector.isEmpty { out.append((filters.sector, { filters.sector = "" })) }
212 + if !filters.unitType.isEmpty { out.append((filters.unitType, { filters.unitType = "" })) }
213 + if let p = filters.priceMin { out.append(("≥ \(p) $", { filters.priceMin = nil })) }
214 + if let p = filters.priceMax { out.append(("≤ \(p) $", { filters.priceMax = nil })) }
215 + if let d = filters.dispoDays {
216 + let label = d == 0 ? "Dispo maintenant" : "Dispo d'ici \(d / 30) mois"
217 + out.append((label, { filters.dispoDays = nil }))
218 + }
219 + if filters.petsOk { out.append(("Animaux acceptés", { filters.petsOk = false })) }
220 + if let f = filters.furnished { out.append((f ? "Meublé" : "Non meublé", { filters.furnished = nil })) }
221 + if let a = filters.areaMin { out.append(("≥ \(a) pi²", { filters.areaMin = nil })) }
222 + if !filters.source.isEmpty { out.append((model.sourceName(filters.source), { filters.source = "" })) }
223 + return out
224 + }
225 +
226 + private var resultsHeader: some View {
227 + HStack(alignment: .firstTextBaseline, spacing: 8) {
228 + if listings != nil {
229 + Text(Fmt.int(total))
230 + .font(LKFont.display(22, .bold))
231 + .foregroundStyle(LK.ink)
232 + Text("logement\(total > 1 ? "s" : "") · triés par prix")
233 + .font(LKFont.mono(11, .medium))
234 + .foregroundStyle(LK.ink3)
235 + }
236 + Spacer()
237 + if filters.activeCount > 0 {
238 + Button {
239 + filters = ListingFilters()
240 + } label: {
241 + Text("Réinitialiser")
242 + .font(LKFont.mono(11, .medium))
243 + .foregroundStyle(LK.danger)
244 + .underline()
245 + }
246 + .buttonStyle(.plain)
247 + }
248 + }
249 + .padding(.top, 4)
250 + }
251 +
252 + @ViewBuilder
253 + private var content: some View {
254 + if let errorMessage {
255 + VStack(spacing: 10) {
256 + Image(systemName: "wifi.exclamationmark").font(.system(size: 28))
257 + Text(errorMessage)
258 + .font(.system(size: 14))
259 + .multilineTextAlignment(.center)
260 + Button("Réessayer") { Task { await load(debounce: false) } }
261 + .font(LKFont.display(14, .bold))
262 + .buttonStyle(.borderedProminent)
263 + .tint(LK.ink)
264 + }
265 + .foregroundStyle(LK.ink2)
266 + .frame(maxWidth: .infinity)
267 + .padding(.vertical, 40)
268 + } else if let listings {
269 + if listings.isEmpty {
270 + VStack(spacing: 8) {
271 + Image(systemName: "tray").font(.system(size: 28))
272 + Text("Aucune annonce pour ces filtres.")
273 + .font(.system(size: 14))
274 + }
275 + .foregroundStyle(LK.ink3)
276 + .frame(maxWidth: .infinity)
277 + .padding(.vertical, 40)
278 + } else {
279 + ForEach(listings) { listing in
280 + NavigationLink(value: listing) {
281 + ListingCardView(listing: listing)
282 + }
283 + .buttonStyle(.plain)
284 + .padding(.trailing, 6) // place pour l'ombre décalée
285 + .padding(.bottom, 6)
286 + }
287 + }
288 + } else {
289 + ProgressView("Chargement…")
290 + .tint(LK.green)
291 + .foregroundStyle(LK.ink2)
292 + .frame(maxWidth: .infinity)
293 + .padding(.vertical, 60)
294 + }
295 + }
296 +}
297 +
298 +// MARK: - Feuille de filtres
299 +
300 +struct FiltersSheet: View {
301 + @Environment(AppModel.self) private var model
302 + @Environment(\.dismiss) private var dismiss
303 + @Binding var filters: ListingFilters
304 + @State private var sectors: [String] = []
305 +
306 + private let priceSteps = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]
307 + private let areaSteps = [400, 600, 800, 1000, 1200]
308 + private let dispoChoices: [(Int?, String)] = [
309 + (nil, "Peu importe"), (0, "Maintenant"), (30, "1 mois"), (60, "2 mois"), (90, "3 mois"),
310 + ]
311 +
312 + var body: some View {
313 + NavigationStack {
314 + ScrollView {
315 + VStack(alignment: .leading, spacing: 22) {
316 + HStack(spacing: 14) {
317 + section("Ville") {
318 + Picker("Ville", selection: $filters.city) {
319 + Text("Toutes").tag("")
320 + ForEach(model.facets?.cities ?? [], id: \.self) {
321 + Text($0).tag($0)
322 + }
323 + }
324 + .pickerStyle(.menu)
325 + .tint(LK.green)
326 + }
327 + section("Quartier") {
328 + Picker("Quartier", selection: $filters.sector) {
329 + Text("Tous").tag("")
330 + ForEach(sectors, id: \.self) {
331 + Text($0).tag($0)
332 + }
333 + }
334 + .pickerStyle(.menu)
335 + .tint(LK.green)
336 + .disabled(sectors.isEmpty)
337 + }
338 + Spacer()
339 + }
340 +
341 + section("Loyer mensuel") {
342 + HStack(spacing: 10) {
343 + rangeMenu(label: filters.priceMin.map { "\($0) $" } ?? "Min",
344 + isSet: filters.priceMin != nil) {
345 + Button("Min") { filters.priceMin = nil }
346 + ForEach(priceSteps.filter { filters.priceMax == nil || $0 < filters.priceMax! }, id: \.self) { p in
347 + Button("\(p) $") { filters.priceMin = p }
348 + }
349 + }
350 + Text("—")
351 + .font(LKFont.mono(12))
352 + .foregroundStyle(LK.ink3)
353 + rangeMenu(label: filters.priceMax.map { "\($0) $" } ?? "Max",
354 + isSet: filters.priceMax != nil) {
355 + Button("Max") { filters.priceMax = nil }
356 + ForEach(priceSteps.filter { filters.priceMin == nil || $0 > filters.priceMin! }, id: \.self) { p in
357 + Button("\(p) $") { filters.priceMax = p }
358 + }
359 + }
360 + Spacer()
361 + }
362 + }
363 +
364 + section("Disponibilité") {
365 + segments(dispoChoices.map(\.1), selectedIndex: dispoChoices.firstIndex(where: { $0.0 == filters.dispoDays }) ?? 0) { i in
366 + filters.dispoDays = dispoChoices[i].0
367 + }
368 + }
369 +
370 + section("Meublé") {
371 + segments(["Peu importe", "Oui", "Non"],
372 + selectedIndex: filters.furnished == nil ? 0 : (filters.furnished! ? 1 : 2)) { i in
373 + filters.furnished = i == 0 ? nil : (i == 1)
374 + }
375 + }
376 +
377 + section("Animaux") {
378 + segments(["Peu importe", "🐾 Acceptés"], selectedIndex: filters.petsOk ? 1 : 0) { i in
379 + filters.petsOk = i == 1
380 + }
381 + }
382 +
383 + section("Superficie minimale") {
384 + ScrollView(.horizontal, showsIndicators: false) {
385 + HStack(spacing: 8) {
386 + Chip(label: "Peu importe", selected: filters.areaMin == nil) {
387 + filters.areaMin = nil
388 + }
389 + ForEach(areaSteps, id: \.self) { a in
390 + Chip(label: "\(a) pi² +", selected: filters.areaMin == a) {
391 + filters.areaMin = filters.areaMin == a ? nil : a
392 + }
393 + }
394 + }
395 + }
396 + }
397 +
398 + section("Gestionnaire") {
399 + Picker("Gestionnaire", selection: $filters.source) {
400 + Text("Tous").tag("")
401 + ForEach(model.facets?.sources ?? [], id: \.source) { s in
402 + Text("\(model.sourceName(s.source)) (\(s.n))").tag(s.source)
403 + }
404 + }
405 + .pickerStyle(.menu)
406 + .tint(LK.green)
407 + }
408 + }
409 + .padding(20)
410 + .padding(.bottom, 10)
411 + }
412 + .background(LK.paper)
413 + .navigationTitle("Filtres")
414 + .navigationBarTitleDisplayMode(.inline)
415 + .toolbar {
416 + ToolbarItem(placement: .topBarLeading) {
417 + Button("Réinitialiser") { filters = ListingFilters() }
418 + .font(LKFont.mono(12, .medium))
419 + .foregroundStyle(LK.danger)
420 + }
421 + ToolbarItem(placement: .topBarTrailing) {
422 + Button("Terminé") { dismiss() }
423 + .font(LKFont.display(15, .bold))
424 + .foregroundStyle(LK.ink)
425 + }
426 + }
427 + // quartiers dépendants de la ville choisie
428 + .task(id: filters.city) {
429 + let f = try? await API.facets(city: filters.city.isEmpty ? nil : filters.city)
430 + sectors = f?.sectors ?? []
431 + if !filters.sector.isEmpty && !sectors.contains(filters.sector) {
432 + filters.sector = ""
433 + }
434 + }
435 + }
436 + .preferredColorScheme(.light)
437 + }
438 +
439 + // MARK: composants
440 +
441 + private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
442 + VStack(alignment: .leading, spacing: 10) {
443 + Kicker(text: title)
444 + content()
445 + }
446 + .frame(maxWidth: .infinity, alignment: .leading)
447 + }
448 +
449 + /// menu déroulant min/max stylé pilule
450 + private func rangeMenu(label: String, isSet: Bool, @ViewBuilder items: () -> some View) -> some View {
451 + Menu {
452 + items()
453 + } label: {
454 + HStack(spacing: 6) {
455 + Text(label)
456 + .font(LKFont.display(14.5, .medium))
457 + Image(systemName: "chevron.down")
458 + .font(.system(size: 9, weight: .bold))
459 + }
460 + .padding(.horizontal, 14)
461 + .padding(.vertical, 10)
462 + .background(isSet ? LK.ink : LK.surface)
463 + .foregroundStyle(isSet ? LK.lime : LK.ink)
464 + .clipShape(Capsule())
465 + .overlay(Capsule().stroke(LK.ink, lineWidth: 1.5))
466 + }
467 + }
468 +
469 + /// segments soudés (équivalent du .seg du site web)
470 + private func segments(_ labels: [String], selectedIndex: Int, tap: @escaping (Int) -> Void) -> some View {
471 + HStack(spacing: 0) {
472 + ForEach(Array(labels.enumerated()), id: \.offset) { i, label in
473 + Button {
474 + tap(i)
475 + } label: {
476 + Text(label)
477 + .font(LKFont.display(13, .medium))
478 + .padding(.horizontal, 13)
479 + .padding(.vertical, 9)
480 + .frame(maxWidth: .infinity)
481 + .background(i == selectedIndex ? LK.ink : LK.surface)
482 + .foregroundStyle(i == selectedIndex ? LK.lime : LK.ink2)
483 + }
484 + .buttonStyle(.plain)
485 + if i < labels.count - 1 {
486 + Rectangle().fill(LK.ink).frame(width: 1.5)
487 + }
488 + }
489 + }
490 + .clipShape(RoundedRectangle(cornerRadius: 9))
491 + .overlay(RoundedRectangle(cornerRadius: 9).stroke(LK.ink, lineWidth: 1.5))
492 + .fixedSize(horizontal: false, vertical: true)
493 + }
494 +}
added LouKa/Views/ListingCardView.swift +130 −0
@@ -0,0 +1,130 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// ListingCardView.swift : carte d'annonce (photo, prix, adresse, badges)
5 +// -----------------------------------------------------------------------------
6 +import SwiftUI
7 +
8 +struct ListingCardView: View {
9 + @Environment(AppModel.self) private var model
10 + let listing: Listing
11 +
12 + var body: some View {
13 + VStack(alignment: .leading, spacing: 0) {
14 + photo
15 + 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)
40 +
41 + Rectangle().fill(LK.line).frame(height: 1).padding(.vertical, 2)
42 +
43 + 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 + }
62 +
63 + 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 + }
76 +
77 + 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 in
82 + switch phase {
83 + case .success(let image):
84 + image.resizable().aspectRatio(contentMode: .fill)
85 + case .failure:
86 + placeholder
87 + default:
88 + LK.surface2
89 + }
90 + }
91 + } else {
92 + placeholder
93 + }
94 + }
95 + .frame(height: 190)
96 + .frame(maxWidth: .infinity)
97 + .clipped()
98 +
99 + 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 + }
121 +
122 + private var placeholder: some View {
123 + ZStack {
124 + LK.limeSoft
125 + Image(systemName: "house.lodge")
126 + .font(.system(size: 36))
127 + .foregroundStyle(LK.green.opacity(0.5))
128 + }
129 + }
130 +}
added LouKa/Views/ListingDetailView.swift +461 −0
@@ -0,0 +1,461 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// ListingDetailView.swift : fiche complète — galerie, faits, description
5 +// structurée (digest), commodités, carte, quartier, lien vers la source
6 +// -----------------------------------------------------------------------------
7 +import SwiftUI
8 +import MapKit
9 +
10 +struct ListingDetailView: View {
11 + @Environment(AppModel.self) private var model
12 + /// annonce venue de la liste (partielle) — remplacée par la fiche complète
13 + let preview: Listing
14 + @State private var full: Listing?
15 +
16 + private var listing: Listing { full ?? preview }
17 +
18 + var body: some View {
19 + ScrollView {
20 + VStack(alignment: .leading, spacing: 20) {
21 + gallery
22 + header
23 + factsGrid
24 + inclusionsSection
25 + amenitiesSection
26 + descriptionSection
27 + mapSection
28 + poiSection
29 + quartierSection
30 + sourceFooter
31 + }
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 + }
43 +
44 + // MARK: galerie
45 +
46 + private var gallery: some View {
47 + Group {
48 + if listing.images.isEmpty {
49 + ZStack {
50 + LK.limeSoft
51 + 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 in
59 + AsyncImage(url: URL(string: src)) { phase in
60 + switch phase {
61 + case .success(let image):
62 + image.resizable().aspectRatio(contentMode: .fill)
63 + case .failure:
64 + ZStack {
65 + LK.surface2
66 + Image(systemName: "photo").foregroundStyle(LK.ink3)
67 + }
68 + default:
69 + LK.surface2
70 + }
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 + }
85 +
86 + // MARK: en-tête
87 +
88 + 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 + priceDropNote
105 + 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 + }
120 +
121 + @ViewBuilder
122 + 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 + }
138 +
139 + // MARK: faits
140 +
141 + private struct Fact: Identifiable {
142 + let id = UUID()
143 + let icon: String
144 + let label: String
145 + let value: String
146 + }
147 +
148 + 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 out
174 + }
175 +
176 + @ViewBuilder
177 + private var factsGrid: some View {
178 + if !facts.isEmpty {
179 + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) {
180 + ForEach(facts) { f in
181 + 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 + }
207 +
208 + // MARK: inclusions & commodités
209 +
210 + 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 + }
230 +
231 + @ViewBuilder
232 + 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 + }
240 +
241 + @ViewBuilder
242 + 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 + }
250 +
251 + 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 in
254 + 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 + }
274 +
275 + // MARK: description (digest structuré si présent)
276 +
277 + @ViewBuilder
278 + private var descriptionSection: some View {
279 + let digest = listing.digest
280 + 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 in
295 + 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 + }
311 +
312 + // MARK: carte & environs
313 +
314 + @ViewBuilder
315 + 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 + }
334 +
335 + 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 + ]
341 +
342 + @ViewBuilder
343 + 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 in
349 + 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 + }
375 +
376 + @ViewBuilder
377 + 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 in
391 + 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 + }
411 +
412 + // MARK: source
413 +
414 + 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 + }
427 +
428 + 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 +}
455 +
456 +private extension Array where Element: Hashable {
457 + func removingDuplicates() -> [Element] {
458 + var seen = Set<Element>()
459 + return filter { seen.insert($0).inserted }
460 + }
461 +}
added LouKa/Views/MapTabView.swift +157 −0
@@ -0,0 +1,157 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// MapTabView.swift : carte province — pastilles d'annonces, mini-carte au tap
5 +// -----------------------------------------------------------------------------
6 +import SwiftUI
7 +import MapKit
8 +
9 +private struct CityPreset: Identifiable {
10 + let name: String
11 + let center: CLLocationCoordinate2D
12 + let span: Double
13 + var id: String { name }
14 +}
15 +
16 +private let CITY_PRESETS: [CityPreset] = [
17 + CityPreset(name: "Québec", center: .init(latitude: 46.8139, longitude: -71.2379), span: 0.30),
18 + CityPreset(name: "Lévis", center: .init(latitude: 46.7382, longitude: -71.2465), span: 0.25),
19 + CityPreset(name: "Montréal", center: .init(latitude: 45.5231, longitude: -73.5817), span: 0.30),
20 + CityPreset(name: "Gatineau", center: .init(latitude: 45.4765, longitude: -75.7013), span: 0.25),
21 + CityPreset(name: "Sherbrooke", center: .init(latitude: 45.4042, longitude: -71.8929), span: 0.25),
22 + CityPreset(name: "Trois-Rivières", center: .init(latitude: 46.3432, longitude: -72.5430), span: 0.25),
23 +]
24 +
25 +struct MapTabView: View {
26 + @State private var listings: [Listing] = []
27 + @State private var selected: Listing?
28 + @State private var detailListing: Listing?
29 + @State private var city = "Québec"
30 + @State private var position: MapCameraPosition = .region(MKCoordinateRegion(
31 + center: CITY_PRESETS[0].center,
32 + span: MKCoordinateSpan(latitudeDelta: 0.30, longitudeDelta: 0.30)
33 + ))
34 +
35 + private var located: [Listing] {
36 + listings.filter { $0.lat != nil && $0.lng != nil }
37 + }
38 +
39 + var body: some View {
40 + ZStack(alignment: .top) {
41 + Map(position: $position) {
42 + ForEach(located) { l in
43 + let isSelected = selected?.uid == l.uid
44 + let size: CGFloat = isSelected ? 16 : 12
45 + Annotation("", coordinate: CLLocationCoordinate2D(latitude: l.lat!, longitude: l.lng!)) {
46 + Circle()
47 + .fill(isSelected ? LK.ink : LK.lime)
48 + .overlay(Circle().stroke(LK.ink, lineWidth: 1.5))
49 + .frame(width: size, height: size)
50 + .onTapGesture { selected = l }
51 + }
52 + .annotationTitles(.hidden)
53 + }
54 + }
55 + .ignoresSafeArea(edges: .top)
56 +
57 + cityChips
58 + }
59 + .overlay(alignment: .bottom) { selectedCard }
60 + .sheet(item: $detailListing) { l in
61 + NavigationStack {
62 + ListingDetailView(preview: l)
63 + .toolbar {
64 + ToolbarItem(placement: .topBarTrailing) {
65 + Button("Fermer") { detailListing = nil }
66 + }
67 + }
68 + }
69 + }
70 + .task(id: city) { await load() }
71 + }
72 +
73 + private func load() async {
74 + var f = ListingFilters()
75 + f.city = city
76 + selected = nil
77 + if let r = try? await API.listings(f) {
78 + listings = r.listings
79 + }
80 + if let preset = CITY_PRESETS.first(where: { $0.name == city }) {
81 + withAnimation {
82 + position = .region(MKCoordinateRegion(
83 + center: preset.center,
84 + span: MKCoordinateSpan(latitudeDelta: preset.span, longitudeDelta: preset.span)
85 + ))
86 + }
87 + }
88 + }
89 +
90 + private var cityChips: some View {
91 + ScrollView(.horizontal, showsIndicators: false) {
92 + HStack(spacing: 8) {
93 + ForEach(CITY_PRESETS) { p in
94 + Chip(label: p.name, selected: city == p.name) {
95 + city = p.name
96 + }
97 + .shadow(color: .black.opacity(0.12), radius: 3, y: 1)
98 + }
99 + }
100 + .padding(.horizontal, 16)
101 + .padding(.vertical, 10)
102 + }
103 + }
104 +
105 + @ViewBuilder
106 + private var selectedCard: some View {
107 + if let l = selected {
108 + Button {
109 + detailListing = l
110 + } label: {
111 + HStack(spacing: 12) {
112 + Group {
113 + if let src = l.images.first, let url = URL(string: src) {
114 + AsyncImage(url: url) { phase in
115 + if case .success(let image) = phase {
116 + image.resizable().aspectRatio(contentMode: .fill)
117 + } else {
118 + LK.limeSoft
119 + }
120 + }
121 + } else {
122 + LK.limeSoft
123 + }
124 + }
125 + .frame(width: 74, height: 74)
126 + .clipShape(RoundedRectangle(cornerRadius: 8))
127 + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1))
128 +
129 + VStack(alignment: .leading, spacing: 3) {
130 + Text(Fmt.price(l.price, label: l.priceLabel))
131 + .font(LKFont.display(18, .bold))
132 + Text(l.title.isEmpty ? l.address : l.title)
133 + .font(.system(size: 13.5, weight: .semibold))
134 + .lineLimit(1)
135 + HStack(spacing: 6) {
136 + if !l.unitType.isEmpty { UnitTypeBadge(type: l.unitType) }
137 + Text([l.sector, l.city].filter { !$0.isEmpty }.joined(separator: " · "))
138 + .font(.system(size: 12))
139 + .foregroundStyle(LK.ink2)
140 + .lineLimit(1)
141 + }
142 + }
143 + Spacer()
144 + Image(systemName: "chevron.right")
145 + .font(.system(size: 13, weight: .semibold))
146 + .foregroundStyle(LK.ink3)
147 + }
148 + .padding(12)
149 + }
150 + .buttonStyle(.plain)
151 + .lkCard()
152 + .padding(.horizontal, 16)
153 + .padding(.bottom, 14)
154 + .transition(.move(edge: .bottom).combined(with: .opacity))
155 + }
156 + }
157 +}
added LouKa/Views/SourcesView.swift +116 −0
@@ -0,0 +1,116 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// SourcesView.swift : registre des gestionnaires (statut, annonces, dernière sync)
5 +// -----------------------------------------------------------------------------
6 +import SwiftUI
7 +
8 +struct SourcesView: View {
9 + @Environment(AppModel.self) private var model
10 + @State private var query = ""
11 +
12 + private var actives: [SourceInfo] {
13 + model.sources.filter { $0.activeListings > 0 }
14 + }
15 +
16 + private var filtered: [SourceInfo] {
17 + let base = model.sources.sorted {
18 + ($0.activeListings, $1.name) > ($1.activeListings, $0.name)
19 + }
20 + guard !query.isEmpty else { return base }
21 + return base.filter {
22 + $0.name.localizedCaseInsensitiveContains(query)
23 + || $0.id.localizedCaseInsensitiveContains(query)
24 + }
25 + }
26 +
27 + var body: some View {
28 + NavigationStack {
29 + ScrollView {
30 + VStack(alignment: .leading, spacing: 14) {
31 + Kicker(text: "Registre des gestionnaires")
32 + HStack(spacing: 10) {
33 + counter(value: model.sources.count, label: "sources recensées")
34 + counter(value: actives.count, label: "avec annonces")
35 + }
36 + ForEach(filtered) { source in
37 + row(source)
38 + }
39 + }
40 + .padding(16)
41 + .padding(.bottom, 24)
42 + }
43 + .background(LK.paper)
44 + .navigationTitle("Sources")
45 + .navigationBarTitleDisplayMode(.inline)
46 + .searchable(text: $query, prompt: "Chercher un gestionnaire")
47 + .refreshable { await model.loadGlobals(force: true) }
48 + .overlay {
49 + if model.sources.isEmpty {
50 + ProgressView("Chargement…")
51 + }
52 + }
53 + }
54 + }
55 +
56 + private func counter(value: Int, label: String) -> some View {
57 + VStack(alignment: .leading, spacing: 2) {
58 + Text(Fmt.int(value))
59 + .font(LKFont.display(24, .bold))
60 + Text(label.uppercased())
61 + .font(LKFont.mono(9, .medium))
62 + .kerning(0.5)
63 + .foregroundStyle(LK.ink3)
64 + }
65 + .padding(12)
66 + .frame(maxWidth: .infinity, alignment: .leading)
67 + .background(LK.surface)
68 + .clipShape(RoundedRectangle(cornerRadius: 8))
69 + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1))
70 + }
71 +
72 + private func row(_ source: SourceInfo) -> some View {
73 + let connected = source.activeListings > 0
74 + return HStack(spacing: 12) {
75 + Circle()
76 + .fill(connected ? LK.green : LK.amber)
77 + .frame(width: 8, height: 8)
78 + VStack(alignment: .leading, spacing: 2) {
79 + Text(source.name)
80 + .font(LKFont.display(15, .medium))
81 + .lineLimit(1)
82 + HStack(spacing: 6) {
83 + Text(source.id)
84 + .font(LKFont.mono(10))
85 + .foregroundStyle(LK.ink3)
86 + if let sync = Fmt.relative(source.lastSync) {
87 + Text("· sync \(sync)")
88 + .font(.system(size: 10.5))
89 + .foregroundStyle(LK.ink3)
90 + }
91 + }
92 + }
93 + Spacer()
94 + if connected {
95 + Text("\(source.activeListings)")
96 + .font(LKFont.mono(12, .bold))
97 + .padding(.horizontal, 8)
98 + .padding(.vertical, 4)
99 + .background(LK.limeSoft)
100 + .foregroundStyle(LK.greenDeep)
101 + .clipShape(Capsule())
102 + } else {
103 + Text(source.status)
104 + .font(LKFont.mono(9.5, .medium))
105 + .foregroundStyle(LK.amber)
106 + .lineLimit(1)
107 + .frame(maxWidth: 90, alignment: .trailing)
108 + }
109 + }
110 + .padding(.horizontal, 13)
111 + .padding(.vertical, 10)
112 + .background(LK.surface)
113 + .clipShape(RoundedRectangle(cornerRadius: 8))
114 + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.line, lineWidth: 1))
115 + }
116 +}
added LouKa/Views/StatsView.swift +277 −0
@@ -0,0 +1,277 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// StatsView.swift : portrait du marché — tuiles KPI, histogramme des loyers,
5 +// palmarès villes / types, offre (inclusions), baisses de prix récentes
6 +// Dataviz : séries uniques → une seule teinte (vert), texte en encre,
7 +// étiquettes directes sélectives, barres ancrées à la base, bouts arrondis.
8 +// -----------------------------------------------------------------------------
9 +import SwiftUI
10 +
11 +struct StatsView: View {
12 + @State private var stats: DetailedStats?
13 + @State private var failed = false
14 +
15 + var body: some View {
16 + NavigationStack {
17 + ScrollView {
18 + VStack(alignment: .leading, spacing: 24) {
19 + if let s = stats {
20 + kpiTiles(s)
21 + histogramCard(s)
22 + groupCard(title: "Par région", groups: s.byRegion ?? [], unit: "annonces")
23 + groupCard(title: "Villes les plus actives", groups: Array((s.byCity ?? []).prefix(8)), unit: "annonces")
24 + groupCard(title: "Par taille de logement", groups: s.byType ?? [], unit: "annonces")
25 + offreCard(s)
26 + baissesCard(s)
27 + } else if failed {
28 + errorView
29 + } else {
30 + ProgressView("Chargement…")
31 + .frame(maxWidth: .infinity)
32 + .padding(.vertical, 80)
33 + }
34 + }
35 + .padding(16)
36 + .padding(.bottom, 24)
37 + }
38 + .background(LK.paper)
39 + .navigationTitle("Le marché en chiffres")
40 + .navigationBarTitleDisplayMode(.inline)
41 + .refreshable { await load() }
42 + .task { await load() }
43 + }
44 + }
45 +
46 + private func load() async {
47 + failed = false
48 + do {
49 + stats = try await API.detailedStats()
50 + } catch {
51 + if stats == nil { failed = true }
52 + }
53 + }
54 +
55 + private var errorView: some View {
56 + VStack(spacing: 10) {
57 + Image(systemName: "wifi.exclamationmark").font(.system(size: 28))
58 + Text("Impossible de charger les statistiques.")
59 + Button("Réessayer") { Task { await load() } }
60 + .buttonStyle(.borderedProminent)
61 + }
62 + .foregroundStyle(LK.ink2)
63 + .frame(maxWidth: .infinity)
64 + .padding(.vertical, 60)
65 + }
66 +
67 + // MARK: tuiles KPI
68 +
69 + private func kpiTiles(_ s: DetailedStats) -> some View {
70 + let t = s.totals
71 + var tiles: [(String, String)] = []
72 + if let v = t?.total { tiles.append((Fmt.int(v), "annonces actives")) }
73 + if let v = t?.median { tiles.append((Fmt.price(v.rounded()), "loyer médian")) }
74 + if let v = t?.avg { tiles.append((Fmt.price(v.rounded()), "loyer moyen")) }
75 + if let v = t?.dispoNow { tiles.append((Fmt.int(v), "libres maintenant")) }
76 + if let v = t?.sources { tiles.append((Fmt.int(v), "sources actives")) }
77 + if let v = t?.cities { tiles.append((Fmt.int(v), "villes couvertes")) }
78 + return LazyVGrid(columns: [GridItem(.adaptive(minimum: 105), spacing: 10)], spacing: 10) {
79 + ForEach(tiles, id: \.1) { value, label in
80 + VStack(alignment: .leading, spacing: 3) {
81 + Text(value)
82 + .font(LKFont.display(21, .bold))
83 + .kerning(-0.5)
84 + .lineLimit(1)
85 + .minimumScaleFactor(0.6)
86 + Text(label.uppercased())
87 + .font(LKFont.mono(8.5, .medium))
88 + .kerning(0.4)
89 + .foregroundStyle(LK.ink3)
90 + .lineLimit(2)
91 + }
92 + .padding(12)
93 + .frame(maxWidth: .infinity, minHeight: 68, alignment: .topLeading)
94 + .background(LK.surface)
95 + .clipShape(RoundedRectangle(cornerRadius: 8))
96 + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.ink, lineWidth: 1.5))
97 + }
98 + }
99 + .padding(.top, 8)
100 + }
101 +
102 + // MARK: histogramme des loyers (série unique — teinte verte, base ancrée)
103 +
104 + @ViewBuilder
105 + private func histogramCard(_ s: DetailedStats) -> some View {
106 + if let buckets = s.histogram, !buckets.isEmpty {
107 + let maxCount = buckets.map(\.count).max() ?? 1
108 + card(title: "Distribution des loyers") {
109 + VStack(spacing: 6) {
110 + HStack(alignment: .bottom, spacing: 2) {
111 + ForEach(buckets, id: \.self) { b in
112 + VStack(spacing: 3) {
113 + // étiquette directe : seulement le pic (sélectif)
114 + if b.count == maxCount {
115 + Text(Fmt.int(b.count))
116 + .font(LKFont.mono(9.5, .medium))
117 + .foregroundStyle(LK.ink2)
118 + .fixedSize()
119 + }
120 + UnevenRoundedRectangle(topLeadingRadius: 3, topTrailingRadius: 3)
121 + .fill(LK.green)
122 + .frame(height: max(3, 110 * CGFloat(b.count) / CGFloat(maxCount)))
123 + }
124 + .frame(maxWidth: .infinity, alignment: .bottom)
125 + }
126 + }
127 + .frame(height: 132, alignment: .bottom)
128 + Rectangle().fill(LK.line).frame(height: 1)
129 + HStack {
130 + Text(axisLabel(buckets.first))
131 + Spacer()
132 + Text("loyer mensuel")
133 + Spacer()
134 + Text(axisLabel(buckets.last, last: true))
135 + }
136 + .font(LKFont.mono(9.5))
137 + .foregroundStyle(LK.ink3)
138 + }
139 + }
140 + }
141 + }
142 +
143 + private func axisLabel(_ b: DetailedStats.Bucket?, last: Bool = false) -> String {
144 + guard let b else { return "" }
145 + if last, b.hi == nil { return "\(Int(b.lo)) $ +" }
146 + return "\(Int(b.lo)) $"
147 + }
148 +
149 + // MARK: palmarès (barres horizontales, série unique)
150 +
151 + @ViewBuilder
152 + private func groupCard(title: String, groups: [DetailedStats.Group], unit: String) -> some View {
153 + if !groups.isEmpty {
154 + let maxCount = groups.map(\.count).max() ?? 1
155 + card(title: title) {
156 + VStack(spacing: 10) {
157 + ForEach(groups, id: \.key) { g in
158 + VStack(alignment: .leading, spacing: 3) {
159 + HStack(alignment: .firstTextBaseline) {
160 + Text(g.key.isEmpty ? "—" : g.key)
161 + .font(.system(size: 13, weight: .semibold))
162 + .lineLimit(1)
163 + Spacer()
164 + Text(Fmt.int(g.count))
165 + .font(LKFont.mono(11.5, .bold))
166 + if let avg = g.avgPrice {
167 + Text("· moy \(Fmt.price(avg.rounded()))")
168 + .font(.system(size: 11))
169 + .foregroundStyle(LK.ink3)
170 + }
171 + }
172 + GeometryReader { geo in
173 + ZStack(alignment: .leading) {
174 + Capsule().fill(LK.line.opacity(0.5))
175 + Capsule()
176 + .fill(LK.green)
177 + .frame(width: max(6, geo.size.width * CGFloat(g.count) / CGFloat(maxCount)))
178 + }
179 + }
180 + .frame(height: 7)
181 + }
182 + }
183 + }
184 + }
185 + }
186 + }
187 +
188 + // MARK: offre (pourcentages d'inclusions)
189 +
190 + @ViewBuilder
191 + private func offreCard(_ s: DetailedStats) -> some View {
192 + if let o = s.offre {
193 + let rows: [(String, Double)] = [
194 + o.chauffagePct.map { ("Chauffage inclus", $0) },
195 + o.electricitePct.map { ("Électricité incluse", $0) },
196 + o.internetPct.map { ("Internet inclus", $0) },
197 + o.stationnementPct.map { ("Stationnement", $0) },
198 + o.climPct.map { ("Climatisation", $0) },
199 + o.balconPct.map { ("Balcon", $0) },
200 + o.furnishedPct.map { ("Meublé", $0) },
201 + o.petsOuiPct.map { ("Animaux acceptés", $0) },
202 + ].compactMap { $0 }
203 + if !rows.isEmpty {
204 + card(title: "Ce que l'offre inclut") {
205 + VStack(spacing: 9) {
206 + ForEach(rows, id: \.0) { label, pct in
207 + HStack(spacing: 10) {
208 + Text(label)
209 + .font(.system(size: 13, weight: .medium))
210 + .frame(width: 150, alignment: .leading)
211 + GeometryReader { geo in
212 + ZStack(alignment: .leading) {
213 + Capsule().fill(LK.line.opacity(0.5))
214 + Capsule()
215 + .fill(LK.green)
216 + .frame(width: max(4, geo.size.width * min(1, pct / 100)))
217 + }
218 + }
219 + .frame(height: 7)
220 + Text("\(Int(pct.rounded())) %")
221 + .font(LKFont.mono(11.5, .bold))
222 + .frame(width: 42, alignment: .trailing)
223 + }
224 + }
225 + }
226 + }
227 + }
228 + }
229 + }
230 +
231 + // MARK: baisses de prix
232 +
233 + @ViewBuilder
234 + private func baissesCard(_ s: DetailedStats) -> some View {
235 + if let baisses = s.baisses, !baisses.isEmpty {
236 + card(title: "Baisses de prix récentes") {
237 + VStack(spacing: 0) {
238 + ForEach(Array(baisses.prefix(6).enumerated()), id: \.offset) { i, b in
239 + HStack(spacing: 10) {
240 + VStack(alignment: .leading, spacing: 1) {
241 + Text(b.title)
242 + .font(.system(size: 13, weight: .semibold))
243 + .lineLimit(1)
244 + Text(b.city)
245 + .font(.system(size: 11))
246 + .foregroundStyle(LK.ink3)
247 + }
248 + Spacer()
249 + VStack(alignment: .trailing, spacing: 1) {
250 + Text("\(Fmt.price(b.avant))\(Fmt.price(b.apres))")
251 + .font(LKFont.mono(11, .medium))
252 + Text("−\(Int(abs(b.pct).rounded())) %")
253 + .font(.system(size: 11, weight: .bold))
254 + .foregroundStyle(LK.green)
255 + }
256 + }
257 + .padding(.vertical, 8)
258 + if i < min(baisses.count, 6) - 1 { Divider() }
259 + }
260 + }
261 + }
262 + }
263 + }
264 +
265 + // MARK: conteneur de carte
266 +
267 + private func card(title: String, @ViewBuilder content: () -> some View) -> some View {
268 + VStack(alignment: .leading, spacing: 14) {
269 + Kicker(text: title)
270 + content()
271 + }
272 + .padding(15)
273 + .frame(maxWidth: .infinity, alignment: .leading)
274 + .lkCard()
275 + .padding(.trailing, 5)
276 + }
277 +}
added README.md +151 −0
@@ -0,0 +1,151 @@
1 +<div align="center">
2 +
3 +# Lou·Ka — app iOS
4 +
5 +### Tous les logements à louer du Québec. Dans votre poche.
6 +
7 +**Compagnon natif de [www.lou-ka.com](https://www.lou-ka.com)** · [dépôt web](https://git.spboucher.ai/lou-ka.git)
8 +
9 +![Swift](https://img.shields.io/badge/Swift-5.9-141814?style=for-the-badge&logo=swift&logoColor=d9f26b)
10 +![SwiftUI](https://img.shields.io/badge/SwiftUI-iOS_17%2B-141814?style=for-the-badge&logo=apple&logoColor=d9f26b)
11 +![XcodeGen](https://img.shields.io/badge/XcodeGen-project.yml-141814?style=for-the-badge&logoColor=d9f26b)
12 +![TestFlight](https://img.shields.io/badge/TestFlight-1.2_(3)-141814?style=for-the-badge&logoColor=d9f26b)
13 +
14 +![Annonces](https://img.shields.io/badge/annonces_live-9000%2B-1c5c41?style=flat-square)
15 +![Sources](https://img.shields.io/badge/sources-190%2B-1c5c41?style=flat-square)
16 +![Reco](https://img.shields.io/badge/reco-on--device-1c5c41?style=flat-square)
17 +![Design](https://img.shields.io/badge/design-%C3%A9ditorial_sharp-1c5c41?style=flat-square)
18 +
19 +*App SwiftUI 100 % native branchée sur l'API de production Lou-Ka —
20 +mêmes 9 000+ annonces, même design signature papier/encre/lime,
21 +plus un mode Découverte qui apprend vos goûts à chaque swipe.*
22 +
23 +</div>
24 +
25 +---
26 +
27 +## Les cinq onglets
28 +
29 +| | Onglet | Ce qu'on y fait |
30 +|---|---|---|
31 +| 📋 | **Annonces** | Recherche débouncée, chips de taille (1½…Studio), filtres complets (ville, **quartier dépendant de la ville**, loyer min–max, disponibilité, animaux, meublé, superficie, gestionnaire), pastilles de filtres actifs retirables d'un tap |
32 +| 🔥 | **Découvrir** | Une annonce plein écran à la fois, photos défilables façon *stories*. **Swipe à droite = coup de cœur 💚, à gauche = on passe.** L'algorithme apprend et reclasse la pile en continu |
33 +| 🗺 | **Carte** | Pastilles lime sur MapKit, presets de ville (Québec, Lévis, Montréal, Gatineau…), mini-fiche au tap → fiche complète |
34 +| 📊 | **Stats** | Tuiles KPI (médian, moyen, libres maintenant), histogramme des loyers, palmarès régions/villes/tailles, inclusions de l'offre, baisses de prix |
35 +| 🏢 | **Sources** | Registre des 255 gestionnaires : statut, compteurs, dernière synchronisation, recherche |
36 +
37 +La **fiche complète** reprend tout le contenu du site : galerie paginée, faits
38 +(dispo, superficie, étage, stationnement…), inclusions, description structurée
39 +(digest), carte, points d'intérêt à proximité, statistiques de quartier, et le
40 +bouton « Voir l'annonce originale » vers le site du gestionnaire.
41 +
42 +<div align="center">
43 +
44 +| Accueil | Découverte | Fiche | Stats | Carte |
45 +|---|---|---|---|---|
46 +| ![Accueil](docs/captures/accueil.png) | ![Découverte](docs/captures/decouverte.png) | ![Fiche](docs/captures/fiche.png) | ![Stats](docs/captures/stats.png) | ![Carte](docs/captures/carte.png) |
47 +
48 +</div>
49 +
50 +## Le mode Découverte, en 30 secondes
51 +
52 +Chaque swipe nourrit un **moteur de recommandation on-device** (`LouKa/Reco.swift`) :
53 +
54 +1. Chaque annonce est décomposée en caractéristiques : ville, quartier, taille,
55 + tranche de prix (300 $), meublé, animaux, balcon, stationnement, gestionnaire.
56 +2. Pour chaque caractéristique, on maintient un taux d'appréciation
57 + **lissé (Laplace)** : `(👍 + 1) / (👍 + 👎 + 2)`, pondéré par la confiance
58 + `1 + log(1 + n)`.
59 +3. Le score d'une annonce = moyenne pondérée des taux de ses caractéristiques.
60 + La pile est **reclassée toutes les 8 décisions**, et ~1 carte sur 6 vient du
61 + reste du bassin (exploration) pour éviter la bulle de filtre.
62 +4. Tout est persisté localement (`UserDefaults`) — **aucune donnée ne quitte
63 + l'appareil**. Les coups de cœur se retrouvent sous le ♥ en haut à droite.
64 +
65 +Après 4 likes de studios à ~900 $, l'en-tête affiche déjà
66 +« *Vos goûts : Studio · ~900 $* » et la pile s'adapte.
67 +
68 +## Architecture
69 +
70 +```
71 +ios/
72 +├── project.yml # définition XcodeGen (cible, Info.plist, polices)
73 +├── ExportOptions.plist # upload App Store Connect (méthode + team)
74 +└── LouKa/
75 + ├── App.swift # point d'entrée, onglets, apparence nav, thème clair forcé
76 + ├── API.swift # client HTTPS → www.lou-ka.com (filtres complets)
77 + ├── Models.swift # décodage tolérant (un champ inattendu ≠ annonce perdue)
78 + ├── Reco.swift # moteur de recommandation du mode Découverte
79 + ├── Theme.swift # design system : palette, ombres décalées, typo, fr-CA
80 + ├── Fonts/ # Space Grotesk (instances statiques) + JetBrains Mono
81 + └── Views/
82 + ├── HomeView.swift # accueil + feuille de filtres
83 + ├── DiscoverView.swift # swipe plein écran + coups de cœur
84 + ├── ListingCardView.swift # carte d'annonce
85 + ├── ListingDetailView.swift # fiche complète
86 + ├── MapTabView.swift # carte province
87 + ├── StatsView.swift # portrait du marché
88 + └── SourcesView.swift # registre des gestionnaires
89 +```
90 +
91 +**Design « éditorial sharp »** — porté du site : papier `#f5f3ee`, encre
92 +`#141814`, vert profond `#1c5c41`, lime électrique `#d9f26b`, bordures encre et
93 +ombres décalées, Space Grotesk pour les titres et les prix, JetBrains Mono pour
94 +les micro-étiquettes. Thème clair **forcé** (comme le site) pour que le mode
95 +sombre du téléphone ne rende jamais un texte illisible.
96 +
97 +> Détail typo : la Space Grotesk variable de Google Fonts n'expose aucun nom
98 +> PostScript d'instance — iOS ne voyait que « Light ». Les instances statiques
99 +> (Regular/Medium/Bold) sont générées avec fontTools et embarquées.
100 +
101 +## Développer
102 +
103 +```bash
104 +brew install xcodegen
105 +cd ios
106 +xcodegen generate # (ré)génère LouKa.xcodeproj depuis project.yml
107 +open LouKa.xcodeproj # ⌘R sur un simulateur iOS 17+
108 +```
109 +
110 +Aucune dépendance externe — SwiftUI, MapKit, Observation, c'est tout.
111 +L'app pointe sur l'API de production ; pour une instance locale, changez
112 +`API.base` dans `LouKa/API.swift`.
113 +
114 +## Publier sur TestFlight
115 +
116 +```bash
117 +# bump MARKETING_VERSION / CURRENT_PROJECT_VERSION dans project.yml, puis :
118 +xcodegen generate
119 +xcodebuild archive -project LouKa.xcodeproj -scheme LouKa \
120 + -destination 'generic/platform=iOS' -archivePath build/LouKa.xcarchive \
121 + -allowProvisioningUpdates
122 +xcodebuild -exportArchive -archivePath build/LouKa.xcarchive \
123 + -exportOptionsPlist ExportOptions.plist -allowProvisioningUpdates
124 +```
125 +
126 +L'export téléverse directement sur App Store Connect (signature automatique,
127 +fiche `com.spboucher.louka` existante) ; la build apparaît dans TestFlight
128 +après le traitement d'Apple.
129 +
130 +## Principes
131 +
132 +1. **Fidélité au web** — mêmes données, même langage visuel, aucun prix inventé.
133 +2. **Robustesse** — décodage champ par champ avec repli : une source mal formée
134 + ne fait jamais disparaître les autres annonces.
135 +3. **Vie privée** — le profil de goûts reste sur l'appareil, point.
136 +4. **Zéro dépendance** — pas de SPM, pas de Pods : le SDK Apple suffit.
137 +
138 +---
139 +
140 +<div align="center">
141 +
142 +## Auteur
143 +
144 +**Simon-Pierre Boucher**
145 +
146 +[![Email](https://img.shields.io/badge/contact@spboucher.ai-141814?style=for-the-badge&logo=minutemailer&logoColor=d9f26b)](mailto:contact@spboucher.ai)
147 +[![Git](https://img.shields.io/badge/git.spboucher.ai-141814?style=for-the-badge&logo=git&logoColor=d9f26b)](https://git.spboucher.ai)
148 +
149 +© 2026 Simon-Pierre Boucher — tous droits réservés.
150 +
151 +</div>
added docs/captures/accueil.png +0 −0

Binary file not shown.

added docs/captures/carte.png +0 −0

Binary file not shown.

added docs/captures/decouverte.png +0 −0

Binary file not shown.

added docs/captures/fiche.png +0 −0

Binary file not shown.

added docs/captures/stats.png +0 −0

Binary file not shown.

added project.yml +60 −0
@@ -0,0 +1,60 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# project.yml : définition XcodeGen de l'app iOS native (SwiftUI)
5 +# → régénérer le projet : xcodegen generate (depuis ios/)
6 +# -----------------------------------------------------------------------------
7 +name: LouKa
8 +options:
9 + bundleIdPrefix: com.spboucher
10 + deploymentTarget:
11 + iOS: "17.0"
12 + createIntermediateGroups: true
13 +
14 +settings:
15 + base:
16 + SWIFT_VERSION: "5.9"
17 + TARGETED_DEVICE_FAMILY: "1,2"
18 +
19 +targets:
20 + LouKa:
21 + type: application
22 + platform: iOS
23 + sources:
24 + - path: LouKa
25 + settings:
26 + base:
27 + PRODUCT_BUNDLE_IDENTIFIER: com.spboucher.louka
28 + MARKETING_VERSION: "1.2"
29 + CURRENT_PROJECT_VERSION: "3"
30 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
31 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
32 + CODE_SIGN_STYLE: Automatic
33 + DEVELOPMENT_TEAM: 3YM54G49SN
34 + SUPPORTS_MACCATALYST: false
35 + info:
36 + path: LouKa/Info.plist
37 + properties:
38 + CFBundleDisplayName: Lou·Ka
39 + CFBundleShortVersionString: "$(MARKETING_VERSION)"
40 + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
41 + UILaunchScreen:
42 + UIColorName: LaunchBackground
43 + UISupportedInterfaceOrientations:
44 + - UIInterfaceOrientationPortrait
45 + UISupportedInterfaceOrientations~ipad:
46 + - UIInterfaceOrientationPortrait
47 + - UIInterfaceOrientationPortraitUpsideDown
48 + - UIInterfaceOrientationLandscapeLeft
49 + - UIInterfaceOrientationLandscapeRight
50 + ITSAppUsesNonExemptEncryption: false
51 + NSAppTransportSecurity:
52 + NSAllowsArbitraryLoads: true
53 + UIAppFonts:
54 + - SpaceGrotesk-Regular.ttf
55 + - SpaceGrotesk-Medium.ttf
56 + - SpaceGrotesk-Bold.ttf
57 + - JetBrainsMono-Regular.ttf
58 + - JetBrainsMono-Medium.ttf
59 + - JetBrainsMono-Bold.ttf
60 + UIUserInterfaceStyle: Light
61