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>
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 | + | |
| 10 | + | |
| 11 | + | |
| 12 | +-141814?style=for-the-badge&logoColor=d9f26b) | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 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 | +|  |  |  |  |  | | |
| 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 | +[](mailto:contact@spboucher.ai) | |
| 147 | +[](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 | ||