Vrai-Prix iOS 1.1.0 — app native SwiftUI du moteur d'évaluation
Onglets Estimer (recherche FTS5 → estimation complète, fourchette P10-P90, confiance A-D, fusion 65/35, historique Swift Charts, comparables avec ajustements en dollars), Carte (MapKit, avoisinage /api/nearby, géolocalisation, fiche → estimation), Manuelle, rapports PDF (PDFKit), À propos. Design « éditorial sharp » du site, polices embarquées, icône générée du wordmark (scripts/make-icon.swift). Projet XcodeGen, 3 tests XCUITest bout en bout contre l'API de production. Build 1.1.0 (1) téléversée sur TestFlight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 32 changed files with +2,254 and −0
added
.DS_Store
+0 −0
Binary file not shown.
added
.gitignore
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +# Xcode | |
| 2 | +xcuserdata/ | |
| 3 | +DerivedData/ | |
| 4 | +*.xcuserstate | |
| 5 | +VraiPrix.xcodeproj/ | |
added
ExportOptions.plist
+16 −0
@@ -0,0 +1,16 @@ | ||
| 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>uploadSymbols</key> | |
| 12 | + <true/> | |
| 13 | + <key>manageAppVersionAndBuildNumber</key> | |
| 14 | + <true/> | |
| 15 | +</dict> | |
| 16 | +</plist> | |
added
README.md
+90 −0
@@ -0,0 +1,90 @@ | ||
| 1 | +<p align="center"> | |
| 2 | + <img src="VraiPrix/Assets.xcassets/AppIcon.appiconset/icon-1024.png" width="120" alt="Icône Vrai-Prix" style="border-radius:24px" /> | |
| 3 | +</p> | |
| 4 | + | |
| 5 | +# Vrai-Prix iOS | |
| 6 | + | |
| 7 | +> **La vraie valeur de votre propriété, sans boîte noire — dans votre poche.** | |
| 8 | +> App iOS native de [Vrai-Prix](https://www.vrai-prix.com), le moteur d'évaluation immobilière transparent pour le Québec. | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | +-141814?style=flat-square&logo=apple) | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | +L'app consomme l'**API de production** (`/api/search`, `/api/nearby`, `/api/estimate`, | |
| 17 | +`/api/report`, `/api/report/pro`) — 3 747 008 unités d'évaluation, 745 119 ventes réelles, | |
| 18 | +MdAPE 11,0 %. Aucune donnée embarquée : le serveur [vrai-prix](https://git.spboucher.ai/vrai-prix) | |
| 19 | +reste la source de vérité. | |
| 20 | + | |
| 21 | +## Fonctionnalités | |
| 22 | + | |
| 23 | +| Onglet | Description | | |
| 24 | +|---|---| | |
| 25 | +| 🔎 **Estimer** | Recherche d'adresse (FTS5) avec autocomplétion débouncée → estimation complète : valeur, fourchette P10–P90, indice de confiance A–D, fusion 65 % modèle hédonique / 35 % comparables, historique 2021→2026 (Swift Charts), caractéristiques du rôle | | |
| 26 | +| 🗺️ **Carte** | Recherche par carte (MapKit) : propriétés avoisinantes, géolocalisation, fiche d'aperçu avec estimation 2026 → estimation complète | | |
| 27 | +| 🎛️ **Manuelle** | Municipalité + type + caractéristiques facultatives, sans adresse exacte | | |
| 28 | +| 📄 **Rapports** | PDF standard (3 p.) et professionnel bancaire (6 p.), visionneuse PDFKit + partage | | |
| 29 | +| 📖 **À propos** | Chiffres de validation IAAO, méthodologie en cinq étapes, conformité Loi 25 | | |
| 30 | + | |
| 31 | +Chaque comparable affiche ses **ajustements en dollars** (conditions de marché, superficie, | |
| 32 | +âge) et son prix ajusté — la transparence totale du moteur, fidèle au site. | |
| 33 | + | |
| 34 | +## Design | |
| 35 | + | |
| 36 | +Identité « éditorial sharp » du site : papier `#f5f3ee`, encre `#141814`, accent rouge vif | |
| 37 | +`#ff5148`, bordeaux `#9e2a25` — bordures encre 1,5 pt, ombres décalées dures, display | |
| 38 | +uppercase, micro-étiquettes mono. Polices embarquées : **Space Grotesk**, **Inter**, | |
| 39 | +**JetBrains Mono**. L'icône est générée depuis le wordmark par `scripts/make-icon.swift` | |
| 40 | +(CoreGraphics + CoreText, 1024×1024 sans alpha) : | |
| 41 | + | |
| 42 | +```bash | |
| 43 | +swift scripts/make-icon.swift VraiPrix/Fonts VraiPrix/Assets.xcassets/AppIcon.appiconset/icon-1024.png | |
| 44 | +``` | |
| 45 | + | |
| 46 | +## Build | |
| 47 | + | |
| 48 | +Le projet Xcode est généré par [XcodeGen](https://github.com/yonaskolb/XcodeGen) — seul | |
| 49 | +`project.yml` est versionné : | |
| 50 | + | |
| 51 | +```bash | |
| 52 | +xcodegen generate # (re)génère VraiPrix.xcodeproj | |
| 53 | +open VraiPrix.xcodeproj # ou en CLI : | |
| 54 | +xcodebuild -project VraiPrix.xcodeproj -scheme VraiPrix \ | |
| 55 | + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build | |
| 56 | +``` | |
| 57 | + | |
| 58 | +### Tests de fumée bout en bout | |
| 59 | + | |
| 60 | +Trois tests XCUITest contre l'API de production (captures dans `/tmp/vp-ui-*.png`) : | |
| 61 | +recherche → estimation, carte → point → fiche → estimation, estimation manuelle. | |
| 62 | + | |
| 63 | +```bash | |
| 64 | +xcodebuild -project VraiPrix.xcodeproj -scheme VraiPrix \ | |
| 65 | + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO test | |
| 66 | +``` | |
| 67 | + | |
| 68 | +## Distribution (TestFlight / App Store) | |
| 69 | + | |
| 70 | +Bundle id `ai.spboucher.vraiprix`, signature automatique (équipe 3YM54G49SN). | |
| 71 | + | |
| 72 | +```bash | |
| 73 | +xcodebuild -project VraiPrix.xcodeproj -scheme VraiPrix \ | |
| 74 | + -destination 'generic/platform=iOS' -configuration Release \ | |
| 75 | + -archivePath /tmp/VraiPrix.xcarchive archive -allowProvisioningUpdates | |
| 76 | +xcodebuild -exportArchive -archivePath /tmp/VraiPrix.xcarchive \ | |
| 77 | + -exportOptionsPlist ExportOptions.plist -allowProvisioningUpdates # method: app-store-connect, destination: upload | |
| 78 | +``` | |
| 79 | + | |
| 80 | +`manageAppVersionAndBuildNumber` gère le numéro de build à l'upload. L'icône sans canal | |
| 81 | +alpha, `ITSAppUsesNonExemptEncryption` et le texte d'usage de la localisation (français) | |
| 82 | +sont déjà conformes pour la revue. | |
| 83 | + | |
| 84 | +## Auteur | |
| 85 | + | |
| 86 | +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 87 | + | |
| 88 | +*Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un | |
| 89 | +évaluateur agréé (OEAQ). Voir [conditions](https://www.vrai-prix.com/conditions) et | |
| 90 | +[méthodologie](https://www.vrai-prix.com/methodologie).* | |
added
VraiPrix/.DS_Store
+0 −0
Binary file not shown.
added
VraiPrix/API.swift
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Client de l'API de production Vrai-Prix (https://www.vrai-prix.com). | |
| 3 | +import Foundation | |
| 4 | + | |
| 5 | +enum APIError: LocalizedError { | |
| 6 | + case server(String) | |
| 7 | + case http(Int) | |
| 8 | + | |
| 9 | + var errorDescription: String? { | |
| 10 | + switch self { | |
| 11 | + case .server(let message): return message | |
| 12 | + case .http(let code): return "Erreur réseau (\(code)). Réessayez." | |
| 13 | + } | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +struct VraiPrixAPI { | |
| 18 | + static let shared = VraiPrixAPI() | |
| 19 | + let base = URL(string: "https://www.vrai-prix.com")! | |
| 20 | + | |
| 21 | + private func decode<T: Decodable>(_ type: T.Type, from data: Data, status: Int) throws -> T { | |
| 22 | + guard (200...299).contains(status) else { | |
| 23 | + if let payload = try? JSONDecoder().decode(APIErrorPayload.self, from: data) { | |
| 24 | + throw APIError.server(payload.error) | |
| 25 | + } | |
| 26 | + throw APIError.http(status) | |
| 27 | + } | |
| 28 | + return try JSONDecoder().decode(type, from: data) | |
| 29 | + } | |
| 30 | + | |
| 31 | + private func get<T: Decodable>(_ type: T.Type, path: String, query: [URLQueryItem]) async throws -> T { | |
| 32 | + var comps = URLComponents(url: base.appending(path: path), resolvingAgainstBaseURL: false)! | |
| 33 | + comps.queryItems = query | |
| 34 | + let (data, response) = try await URLSession.shared.data(from: comps.url!) | |
| 35 | + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 | |
| 36 | + return try decode(type, from: data, status: status) | |
| 37 | + } | |
| 38 | + | |
| 39 | + // MARK: Endpoints | |
| 40 | + | |
| 41 | + func search(_ q: String) async throws -> [SearchResult] { | |
| 42 | + try await get(SearchResponse.self, path: "/api/search", query: [.init(name: "q", value: q)]).results | |
| 43 | + } | |
| 44 | + | |
| 45 | + func nearby(lat: Double, lng: Double, halfLat: Double, halfLng: Double, limit: Int = 250) async throws -> [NearbyUnit] { | |
| 46 | + try await get(NearbyResponse.self, path: "/api/nearby", query: [ | |
| 47 | + .init(name: "lat", value: String(lat)), | |
| 48 | + .init(name: "lng", value: String(lng)), | |
| 49 | + .init(name: "halfLat", value: String(halfLat)), | |
| 50 | + .init(name: "halfLng", value: String(halfLng)), | |
| 51 | + .init(name: "limit", value: String(limit)), | |
| 52 | + ]).results | |
| 53 | + } | |
| 54 | + | |
| 55 | + func estimate(id: String) async throws -> UnitEstimate { | |
| 56 | + try await get(UnitEstimate.self, path: "/api/estimate", query: [.init(name: "id", value: id)]) | |
| 57 | + } | |
| 58 | + | |
| 59 | + func estimateManual( | |
| 60 | + municipality: String, | |
| 61 | + typeProp: String, | |
| 62 | + floorArea: Double?, | |
| 63 | + yearBuilt: Int?, | |
| 64 | + landArea: Double? | |
| 65 | + ) async throws -> UnitEstimate { | |
| 66 | + var body: [String: Any] = ["municipality": municipality, "typeProp": typeProp] | |
| 67 | + if let floorArea { body["floorArea"] = floorArea } | |
| 68 | + if let yearBuilt { body["yearBuilt"] = yearBuilt } | |
| 69 | + if let landArea { body["landArea"] = landArea } | |
| 70 | + | |
| 71 | + var request = URLRequest(url: base.appending(path: "/api/estimate")) | |
| 72 | + request.httpMethod = "POST" | |
| 73 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 74 | + request.httpBody = try JSONSerialization.data(withJSONObject: body) | |
| 75 | + let (data, response) = try await URLSession.shared.data(for: request) | |
| 76 | + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 | |
| 77 | + return try decode(UnitEstimate.self, from: data, status: status) | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Télécharge un rapport PDF dans un fichier temporaire et retourne son URL locale. | |
| 81 | + func downloadReport(path: String, query: [URLQueryItem], fileName: String) async throws -> URL { | |
| 82 | + var comps = URLComponents(url: base.appending(path: path), resolvingAgainstBaseURL: false)! | |
| 83 | + comps.queryItems = query | |
| 84 | + let (data, response) = try await URLSession.shared.data(from: comps.url!) | |
| 85 | + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 | |
| 86 | + guard (200...299).contains(status) else { | |
| 87 | + if let payload = try? JSONDecoder().decode(APIErrorPayload.self, from: data) { | |
| 88 | + throw APIError.server(payload.error) | |
| 89 | + } | |
| 90 | + throw APIError.http(status) | |
| 91 | + } | |
| 92 | + let url = FileManager.default.temporaryDirectory.appending(path: fileName) | |
| 93 | + try data.write(to: url) | |
| 94 | + return url | |
| 95 | + } | |
| 96 | +} | |
added
VraiPrix/Assets.xcassets/.DS_Store
+0 −0
Binary file not shown.
added
VraiPrix/Assets.xcassets/AppIcon.appiconset/Contents.json
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "images" : [ | |
| 3 | + { | |
| 4 | + "filename" : "icon-1024.png", | |
| 5 | + "idiom" : "universal", | |
| 6 | + "platform" : "ios", | |
| 7 | + "size" : "1024x1024" | |
| 8 | + } | |
| 9 | + ], | |
| 10 | + "info" : { | |
| 11 | + "author" : "xcode", | |
| 12 | + "version" : 1 | |
| 13 | + } | |
| 14 | +} | |
added
VraiPrix/Assets.xcassets/AppIcon.appiconset/icon-1024.png
+0 −0
Binary file not shown.
added
VraiPrix/Assets.xcassets/Contents.json
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "info" : { | |
| 3 | + "author" : "xcode", | |
| 4 | + "version" : 1 | |
| 5 | + } | |
| 6 | +} | |
added
VraiPrix/Assets.xcassets/PaperBackground.colorset/Contents.json
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "colors" : [ | |
| 3 | + { | |
| 4 | + "color" : { | |
| 5 | + "color-space" : "srgb", | |
| 6 | + "components" : { | |
| 7 | + "alpha" : "1.000", | |
| 8 | + "blue" : "0xEE", | |
| 9 | + "green" : "0xF3", | |
| 10 | + "red" : "0xF5" | |
| 11 | + } | |
| 12 | + }, | |
| 13 | + "idiom" : "universal" | |
| 14 | + } | |
| 15 | + ], | |
| 16 | + "info" : { | |
| 17 | + "author" : "xcode", | |
| 18 | + "version" : 1 | |
| 19 | + } | |
| 20 | +} | |
added
VraiPrix/Fonts/Inter-Regular.ttf
+0 −0
Binary file not shown.
added
VraiPrix/Fonts/JetBrainsMono-Bold.ttf
+0 −0
Binary file not shown.
added
VraiPrix/Fonts/JetBrainsMono-Regular.ttf
+0 −0
Binary file not shown.
added
VraiPrix/Fonts/SpaceGrotesk-Bold.ttf
+0 −0
Binary file not shown.
added
VraiPrix/Fonts/SpaceGrotesk-Medium.ttf
+0 −0
Binary file not shown.
added
VraiPrix/Format.swift
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Formatage à la québécoise : dollars CAD fr_CA, m², distances. | |
| 3 | +import Foundation | |
| 4 | + | |
| 5 | +enum Fmt { | |
| 6 | + private static let cadFormatter: NumberFormatter = { | |
| 7 | + let f = NumberFormatter() | |
| 8 | + f.numberStyle = .currency | |
| 9 | + f.locale = Locale(identifier: "fr_CA") | |
| 10 | + f.maximumFractionDigits = 0 | |
| 11 | + return f | |
| 12 | + }() | |
| 13 | + | |
| 14 | + private static let numFormatter: NumberFormatter = { | |
| 15 | + let f = NumberFormatter() | |
| 16 | + f.numberStyle = .decimal | |
| 17 | + f.locale = Locale(identifier: "fr_CA") | |
| 18 | + f.maximumFractionDigits = 0 | |
| 19 | + return f | |
| 20 | + }() | |
| 21 | + | |
| 22 | + /// « 833 800 $ » | |
| 23 | + static func cad(_ value: Double?) -> String { | |
| 24 | + guard let value, value.isFinite else { return "—" } | |
| 25 | + return cadFormatter.string(from: NSNumber(value: value.rounded())) ?? "—" | |
| 26 | + } | |
| 27 | + | |
| 28 | + /// « +34 000 $ » / « −83 700 $ » — ajustements du moteur, signe explicite. | |
| 29 | + static func signedCad(_ value: Double) -> String { | |
| 30 | + let s = cad(abs(value)) | |
| 31 | + if abs(value) < 0.5 { return "±0 $" } | |
| 32 | + return (value >= 0 ? "+" : "−") + s | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// « 195 m² » | |
| 36 | + static func m2(_ value: Double?) -> String { | |
| 37 | + guard let value, value.isFinite else { return "—" } | |
| 38 | + return (numFormatter.string(from: NSNumber(value: value.rounded())) ?? "—") + " m²" | |
| 39 | + } | |
| 40 | + | |
| 41 | + static func num(_ value: Double?) -> String { | |
| 42 | + guard let value, value.isFinite else { return "—" } | |
| 43 | + return numFormatter.string(from: NSNumber(value: value.rounded())) ?? "—" | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// « 850 m » ou « 2,4 km » | |
| 47 | + static func distance(_ meters: Double) -> String { | |
| 48 | + if meters < 1000 { return "\(Int(meters.rounded())) m" } | |
| 49 | + return String(format: "%.1f km", locale: Locale(identifier: "fr_CA"), meters / 1000) | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// « 2026-04 » → « avril 2026 » | |
| 53 | + static func monthYear(_ isoDate: String) -> String { | |
| 54 | + let input = DateFormatter() | |
| 55 | + input.dateFormat = "yyyy-MM-dd" | |
| 56 | + input.locale = Locale(identifier: "en_US_POSIX") | |
| 57 | + guard let d = input.date(from: isoDate) else { return isoDate } | |
| 58 | + let output = DateFormatter() | |
| 59 | + output.dateFormat = "MMMM yyyy" | |
| 60 | + output.locale = Locale(identifier: "fr_CA") | |
| 61 | + return output.string(from: d) | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// « 12,3 % » | |
| 65 | + static func pct(_ value: Double?, decimals: Int = 1) -> String { | |
| 66 | + guard let value, value.isFinite else { return "—" } | |
| 67 | + return String(format: "%.\(decimals)f %%", locale: Locale(identifier: "fr_CA"), value) | |
| 68 | + } | |
| 69 | +} | |
added
VraiPrix/Info.plist
+52 −0
@@ -0,0 +1,52 @@ | ||
| 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>Vrai-Prix</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>VraiPrix</string> | |
| 17 | + <key>CFBundlePackageType</key> | |
| 18 | + <string>APPL</string> | |
| 19 | + <key>CFBundleShortVersionString</key> | |
| 20 | + <string>1.1.0</string> | |
| 21 | + <key>CFBundleVersion</key> | |
| 22 | + <string>1</string> | |
| 23 | + <key>ITSAppUsesNonExemptEncryption</key> | |
| 24 | + <false/> | |
| 25 | + <key>NSLocationWhenInUseUsageDescription</key> | |
| 26 | + <string>Votre position sert uniquement à centrer la carte sur votre quartier pour afficher les propriétés avoisinantes. Elle n'est jamais transmise.</string> | |
| 27 | + <key>UIAppFonts</key> | |
| 28 | + <array> | |
| 29 | + <string>SpaceGrotesk-Bold.ttf</string> | |
| 30 | + <string>SpaceGrotesk-Medium.ttf</string> | |
| 31 | + <string>Inter-Regular.ttf</string> | |
| 32 | + <string>JetBrainsMono-Regular.ttf</string> | |
| 33 | + <string>JetBrainsMono-Bold.ttf</string> | |
| 34 | + </array> | |
| 35 | + <key>UILaunchScreen</key> | |
| 36 | + <dict> | |
| 37 | + <key>UIColorName</key> | |
| 38 | + <string>PaperBackground</string> | |
| 39 | + </dict> | |
| 40 | + <key>UISupportedInterfaceOrientations</key> | |
| 41 | + <array> | |
| 42 | + <string>UIInterfaceOrientationPortrait</string> | |
| 43 | + </array> | |
| 44 | + <key>UISupportedInterfaceOrientations~ipad</key> | |
| 45 | + <array> | |
| 46 | + <string>UIInterfaceOrientationPortrait</string> | |
| 47 | + <string>UIInterfaceOrientationPortraitUpsideDown</string> | |
| 48 | + <string>UIInterfaceOrientationLandscapeLeft</string> | |
| 49 | + <string>UIInterfaceOrientationLandscapeRight</string> | |
| 50 | + </array> | |
| 51 | +</dict> | |
| 52 | +</plist> | |
added
VraiPrix/Models.swift
+157 −0
@@ -0,0 +1,157 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Modèles Codable — miroir des types de l'API Vrai-Prix (src/lib/estimator.ts, engine.ts). | |
| 3 | +import Foundation | |
| 4 | + | |
| 5 | +// MARK: - Recherche d'adresse (/api/search) | |
| 6 | + | |
| 7 | +struct SearchResponse: Decodable { | |
| 8 | + let results: [SearchResult] | |
| 9 | +} | |
| 10 | + | |
| 11 | +struct SearchResult: Decodable, Identifiable, Hashable { | |
| 12 | + let id: String | |
| 13 | + let adresse: String? | |
| 14 | + let apt: String? | |
| 15 | + let municipalite: String? | |
| 16 | + let typeProp: String? | |
| 17 | +} | |
| 18 | + | |
| 19 | +// MARK: - Estimation (/api/estimate) | |
| 20 | + | |
| 21 | +struct UnitEstimate: Decodable { | |
| 22 | + let unit: PropertyUnit? | |
| 23 | + let result: EstimateResult | |
| 24 | +} | |
| 25 | + | |
| 26 | +struct PropertyUnit: Decodable { | |
| 27 | + let id: String | |
| 28 | + let adresse: String? | |
| 29 | + let municipalite: String? | |
| 30 | + let typeProp: String | |
| 31 | + let cubfLibelle: String? | |
| 32 | + let anneeConstruction: Int? | |
| 33 | + let aireEtagesM2: Double? | |
| 34 | + let superficieTerrainM2: Double? | |
| 35 | + let nbLogements: Int? | |
| 36 | + let valeurRole: Double? | |
| 37 | + let lat: Double | |
| 38 | + let lng: Double | |
| 39 | + let history: [HistoryPoint] | |
| 40 | + let specs: UnitSpecs | |
| 41 | +} | |
| 42 | + | |
| 43 | +struct HistoryPoint: Decodable, Identifiable { | |
| 44 | + let year: Int | |
| 45 | + let value: Double? | |
| 46 | + var id: Int { year } | |
| 47 | +} | |
| 48 | + | |
| 49 | +struct UnitSpecs: Decodable { | |
| 50 | + let cubf: Int? | |
| 51 | + let cubfLibelle: String? | |
| 52 | + let anneeEstimee: String? | |
| 53 | + let frontTerrainM: Double? | |
| 54 | + let nbEtages: Int? | |
| 55 | + let nbLocauxNonResid: Int? | |
| 56 | + let nbChambresLocatives: Int? | |
| 57 | + let lienPhysique: String? | |
| 58 | + let genreConstruction: String? | |
| 59 | + let matricule: String? | |
| 60 | + let uniteVoisinage: String? | |
| 61 | + let nAdresses: Int? | |
| 62 | + let datCondMarche: String? | |
| 63 | + let valeurTerrain: Double? | |
| 64 | + let valeurBatiment: Double? | |
| 65 | + let valeurAnterieure: Double? | |
| 66 | + let arrond: String? | |
| 67 | + let apt: String? | |
| 68 | +} | |
| 69 | + | |
| 70 | +struct EstimateResult: Decodable { | |
| 71 | + let estimate: Double | |
| 72 | + let low: Double | |
| 73 | + let high: Double | |
| 74 | + let confidencePct: Double | |
| 75 | + let confidenceLevel: String // A | B | C | D | |
| 76 | + let modelEstimate: Double? | |
| 77 | + let compsEstimate: Double? | |
| 78 | + let modelWeight: Double | |
| 79 | + let comps: [AdjustedComp] | |
| 80 | + let nCompsUsed: Int | |
| 81 | + let compsDispersionPct: Double? | |
| 82 | +} | |
| 83 | + | |
| 84 | +struct AdjustedComp: Decodable, Identifiable { | |
| 85 | + let id: String | |
| 86 | + let date: String // YYYY-MM-DD | |
| 87 | + let amount: Double | |
| 88 | + let lat: Double | |
| 89 | + let lng: Double | |
| 90 | + let propertyType: String? | |
| 91 | + let yearBuilt: Int? | |
| 92 | + let floorArea: Double? | |
| 93 | + let street: String? | |
| 94 | + let city: String? | |
| 95 | + let distanceM: Double | |
| 96 | + let monthsAgo: Double | |
| 97 | + let adjTime: Double | |
| 98 | + let adjArea: Double | |
| 99 | + let adjAge: Double | |
| 100 | + let adjustedPrice: Double | |
| 101 | + let weight: Double | |
| 102 | +} | |
| 103 | + | |
| 104 | +// MARK: - Recherche par carte (/api/nearby) | |
| 105 | + | |
| 106 | +struct NearbyResponse: Decodable { | |
| 107 | + let results: [NearbyUnit] | |
| 108 | +} | |
| 109 | + | |
| 110 | +struct NearbyUnit: Decodable, Identifiable, Hashable { | |
| 111 | + let id: String | |
| 112 | + let adresse: String? | |
| 113 | + let apt: String? | |
| 114 | + let municipalite: String? | |
| 115 | + let typeProp: String | |
| 116 | + let lat: Double | |
| 117 | + let lng: Double | |
| 118 | + let est2026: Double? | |
| 119 | + | |
| 120 | + /// Pour réutiliser EstimateLoaderView (même écran que la recherche texte). | |
| 121 | + var asSearchResult: SearchResult { | |
| 122 | + SearchResult(id: id, adresse: adresse, apt: apt, municipalite: municipalite, typeProp: typeProp) | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +/// Erreur renvoyée par l'API ({"error": "..."}). | |
| 127 | +struct APIErrorPayload: Decodable { | |
| 128 | + let error: String | |
| 129 | +} | |
| 130 | + | |
| 131 | +/// Libellés français des types de propriété (mêmes clés que le moteur). | |
| 132 | +enum PropType: String, CaseIterable, Identifiable { | |
| 133 | + case unifamilial | |
| 134 | + case plex | |
| 135 | + case condoOuMulti = "condo_ou_multi" | |
| 136 | + case chalet | |
| 137 | + case maisonMobile = "maison_mobile" | |
| 138 | + case terrain | |
| 139 | + | |
| 140 | + var id: String { rawValue } | |
| 141 | + | |
| 142 | + var label: String { | |
| 143 | + switch self { | |
| 144 | + case .unifamilial: return "Unifamiliale" | |
| 145 | + case .plex: return "Plex" | |
| 146 | + case .condoOuMulti: return "Condo / multi" | |
| 147 | + case .chalet: return "Chalet" | |
| 148 | + case .maisonMobile: return "Maison mobile" | |
| 149 | + case .terrain: return "Terrain" | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + static func label(for raw: String?) -> String { | |
| 154 | + guard let raw, let t = PropType(rawValue: raw) else { return raw ?? "—" } | |
| 155 | + return t.label | |
| 156 | + } | |
| 157 | +} | |
added
VraiPrix/Theme.swift
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Thème « éditorial sharp » de Vrai-Prix : papier grainé, encre, accent rouge vif, | |
| 3 | +// bordures encre 1,5 pt, ombres décalées dures, display uppercase, micro-étiquettes mono. | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +extension Color { | |
| 7 | + init(hex: UInt32) { | |
| 8 | + self.init( | |
| 9 | + .sRGB, | |
| 10 | + red: Double((hex >> 16) & 0xff) / 255, | |
| 11 | + green: Double((hex >> 8) & 0xff) / 255, | |
| 12 | + blue: Double(hex & 0xff) / 255 | |
| 13 | + ) | |
| 14 | + } | |
| 15 | + | |
| 16 | + static let vpPaper = Color(hex: 0xF5F3EE) | |
| 17 | + static let vpSurface = Color(hex: 0xFFFFFF) | |
| 18 | + static let vpSurface2 = Color(hex: 0xFAF9F5) | |
| 19 | + static let vpInk = Color(hex: 0x141814) | |
| 20 | + static let vpInk2 = Color(hex: 0x4D5551) | |
| 21 | + static let vpInk3 = Color(hex: 0x8B928C) | |
| 22 | + static let vpGreen = Color(hex: 0x9E2A25) // bordeaux (nom hérité du site) | |
| 23 | + static let vpGreenDeep = Color(hex: 0x771F1B) | |
| 24 | + static let vpLime = Color(hex: 0xFF5148) // accent rouge vif (nom hérité du site) | |
| 25 | + static let vpLimeSoft = Color(hex: 0xFFE3E0) | |
| 26 | +} | |
| 27 | + | |
| 28 | +extension Font { | |
| 29 | + /// Display — Space Grotesk Bold (titres, chiffres clés) | |
| 30 | + static func vpDisplay(_ size: CGFloat) -> Font { .custom("SpaceGrotesk-Bold", size: size) } | |
| 31 | + /// Display medium — Space Grotesk Medium | |
| 32 | + static func vpDisplayMedium(_ size: CGFloat) -> Font { .custom("SpaceGrotesk-Medium", size: size) } | |
| 33 | + /// Corps — Inter Regular | |
| 34 | + static func vpBody(_ size: CGFloat) -> Font { .custom("Inter-Regular", size: size) } | |
| 35 | + /// Micro-étiquettes — JetBrains Mono | |
| 36 | + static func vpMono(_ size: CGFloat) -> Font { .custom("JetBrainsMono-Regular", size: size) } | |
| 37 | + static func vpMonoBold(_ size: CGFloat) -> Font { .custom("JetBrainsMono-Bold", size: size) } | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Carte signature : fond blanc, bordure encre 1,5 pt, ombre décalée dure. | |
| 41 | +struct VPCard: ViewModifier { | |
| 42 | + var corner: CGFloat = 14 | |
| 43 | + func body(content: Content) -> some View { | |
| 44 | + content | |
| 45 | + .background(RoundedRectangle(cornerRadius: corner).fill(Color.vpSurface)) | |
| 46 | + .overlay(RoundedRectangle(cornerRadius: corner).stroke(Color.vpInk, lineWidth: 1.5)) | |
| 47 | + .background( | |
| 48 | + RoundedRectangle(cornerRadius: corner) | |
| 49 | + .fill(Color.vpInk.opacity(0.22)) | |
| 50 | + .offset(x: 5, y: 5) | |
| 51 | + ) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +extension View { | |
| 56 | + func vpCard(corner: CGFloat = 14) -> some View { modifier(VPCard(corner: corner)) } | |
| 57 | +} | |
| 58 | + | |
| 59 | +/// « Kicker » : petite étiquette mono bordeaux précédée d'un trait — signature du site. | |
| 60 | +struct Kicker: View { | |
| 61 | + let text: String | |
| 62 | + var body: some View { | |
| 63 | + HStack(spacing: 8) { | |
| 64 | + Rectangle().fill(Color.vpGreen).frame(width: 22, height: 2) | |
| 65 | + Text(text.uppercased()) | |
| 66 | + .font(.vpMonoBold(10.5)) | |
| 67 | + .tracking(1.6) | |
| 68 | + .foregroundStyle(Color.vpGreen) | |
| 69 | + } | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +/// Bouton principal : encre pleine, texte accent, ombre décalée. | |
| 74 | +struct VPPrimaryButtonStyle: ButtonStyle { | |
| 75 | + func makeBody(configuration: Configuration) -> some View { | |
| 76 | + configuration.label | |
| 77 | + .font(.vpDisplay(15)) | |
| 78 | + .foregroundStyle(Color.vpLime) | |
| 79 | + .padding(.horizontal, 20) | |
| 80 | + .padding(.vertical, 13) | |
| 81 | + .frame(maxWidth: .infinity) | |
| 82 | + .background( | |
| 83 | + RoundedRectangle(cornerRadius: 12) | |
| 84 | + .fill(configuration.isPressed ? Color.vpGreenDeep : Color.vpInk) | |
| 85 | + ) | |
| 86 | + .background( | |
| 87 | + RoundedRectangle(cornerRadius: 12) | |
| 88 | + .fill(Color.vpInk.opacity(0.25)) | |
| 89 | + .offset(x: 4, y: 4) | |
| 90 | + ) | |
| 91 | + .scaleEffect(configuration.isPressed ? 0.985 : 1) | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +/// Bouton fantôme : bordure encre, fond transparent. | |
| 96 | +struct VPGhostButtonStyle: ButtonStyle { | |
| 97 | + func makeBody(configuration: Configuration) -> some View { | |
| 98 | + configuration.label | |
| 99 | + .font(.vpDisplay(15)) | |
| 100 | + .foregroundStyle(Color.vpInk) | |
| 101 | + .padding(.horizontal, 20) | |
| 102 | + .padding(.vertical, 13) | |
| 103 | + .frame(maxWidth: .infinity) | |
| 104 | + .background( | |
| 105 | + RoundedRectangle(cornerRadius: 12) | |
| 106 | + .fill(configuration.isPressed ? Color.vpLime : Color.clear) | |
| 107 | + ) | |
| 108 | + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.vpInk, lineWidth: 1.5)) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Wordmark « Vrai [Prix] » — reproduction du logo de l'en-tête du site. | |
| 113 | +struct Wordmark: View { | |
| 114 | + var size: CGFloat = 24 | |
| 115 | + var body: some View { | |
| 116 | + HStack(spacing: size * 0.18) { | |
| 117 | + Text("Vrai") | |
| 118 | + .font(.vpDisplay(size)) | |
| 119 | + .foregroundStyle(Color.vpInk) | |
| 120 | + Text("Prix") | |
| 121 | + .font(.vpDisplay(size)) | |
| 122 | + .foregroundStyle(Color.vpLime) | |
| 123 | + .padding(.horizontal, size * 0.3) | |
| 124 | + .padding(.vertical, size * 0.1) | |
| 125 | + .background(RoundedRectangle(cornerRadius: size * 0.26).fill(Color.vpInk)) | |
| 126 | + .rotationEffect(.degrees(-2)) | |
| 127 | + } | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +/// Champ de saisie signature : bordure, focus accent. | |
| 132 | +struct VPFieldStyle: ViewModifier { | |
| 133 | + func body(content: Content) -> some View { | |
| 134 | + content | |
| 135 | + .font(.vpBody(16)) | |
| 136 | + .foregroundStyle(Color.vpInk) | |
| 137 | + .padding(.horizontal, 14) | |
| 138 | + .padding(.vertical, 12) | |
| 139 | + .background(RoundedRectangle(cornerRadius: 12).fill(Color.vpSurface2)) | |
| 140 | + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.vpInk.opacity(0.35), lineWidth: 1.5)) | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +extension View { | |
| 145 | + func vpField() -> some View { modifier(VPFieldStyle()) } | |
| 146 | +} | |
added
VraiPrix/Views/AboutView.swift
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// À propos : la méthodologie en bref, les chiffres de validation, la conformité. | |
| 3 | +import SwiftUI | |
| 4 | + | |
| 5 | +struct AboutView: View { | |
| 6 | + var body: some View { | |
| 7 | + NavigationStack { | |
| 8 | + ScrollView { | |
| 9 | + VStack(alignment: .leading, spacing: 18) { | |
| 10 | + VStack(alignment: .leading, spacing: 10) { | |
| 11 | + Wordmark(size: 34) | |
| 12 | + Text("La vraie valeur, sans boîte noire") | |
| 13 | + .font(.vpDisplay(19)) | |
| 14 | + .foregroundStyle(Color.vpInk) | |
| 15 | + .textCase(.uppercase) | |
| 16 | + Text("Moteur d'évaluation immobilière transparent pour le Québec. Chaque calcul est montré en entier : comparables, ajustements en dollars, pondération, fourchette et confiance.") | |
| 17 | + .font(.vpBody(14.5)) | |
| 18 | + .foregroundStyle(Color.vpInk2) | |
| 19 | + } | |
| 20 | + .padding(.top, 8) | |
| 21 | + | |
| 22 | + statsCard | |
| 23 | + methodCard | |
| 24 | + complianceCard | |
| 25 | + | |
| 26 | + VStack(alignment: .leading, spacing: 8) { | |
| 27 | + Link("www.vrai-prix.com", destination: URL(string: "https://www.vrai-prix.com")!) | |
| 28 | + .font(.vpDisplayMedium(15)) | |
| 29 | + .foregroundStyle(Color.vpGreen) | |
| 30 | + Link("Méthodologie complète", destination: URL(string: "https://www.vrai-prix.com/methodologie")!) | |
| 31 | + .font(.vpBody(13.5)) | |
| 32 | + .foregroundStyle(Color.vpInk2) | |
| 33 | + Link("Conditions d'utilisation", destination: URL(string: "https://www.vrai-prix.com/conditions")!) | |
| 34 | + .font(.vpBody(13.5)) | |
| 35 | + .foregroundStyle(Color.vpInk2) | |
| 36 | + Link("Politique de confidentialité", destination: URL(string: "https://www.vrai-prix.com/confidentialite")!) | |
| 37 | + .font(.vpBody(13.5)) | |
| 38 | + .foregroundStyle(Color.vpInk2) | |
| 39 | + Text("Simon-Pierre Boucher — contact@spboucher.ai") | |
| 40 | + .font(.vpMono(10)) | |
| 41 | + .foregroundStyle(Color.vpInk3) | |
| 42 | + .padding(.top, 6) | |
| 43 | + } | |
| 44 | + .padding(.bottom, 16) | |
| 45 | + } | |
| 46 | + .padding(16) | |
| 47 | + } | |
| 48 | + .background(Color.vpPaper) | |
| 49 | + .toolbar { | |
| 50 | + ToolbarItem(placement: .principal) { Wordmark(size: 20) } | |
| 51 | + } | |
| 52 | + .navigationBarTitleDisplayMode(.inline) | |
| 53 | + .toolbarBackground(Color.vpPaper, for: .navigationBar) | |
| 54 | + .toolbarBackground(.visible, for: .navigationBar) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + private var statsCard: some View { | |
| 59 | + VStack(alignment: .leading, spacing: 12) { | |
| 60 | + Kicker(text: "Les chiffres qui comptent") | |
| 61 | + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { | |
| 62 | + stat("3 747 008", "unités d'évaluation") | |
| 63 | + stat("745 119", "ventes réelles") | |
| 64 | + stat("11,0 %", "erreur médiane (MdAPE)") | |
| 65 | + stat("0,995", "ratio médian (IAAO ✓)") | |
| 66 | + stat("2,01 billions $", "valeur de la province") | |
| 67 | + stat("99,88 %", "fusion spatiale réussie") | |
| 68 | + } | |
| 69 | + } | |
| 70 | + .padding(18) | |
| 71 | + .vpCard() | |
| 72 | + } | |
| 73 | + | |
| 74 | + private func stat(_ value: String, _ label: String) -> some View { | |
| 75 | + VStack(alignment: .leading, spacing: 3) { | |
| 76 | + Text(value) | |
| 77 | + .font(.vpDisplay(19)) | |
| 78 | + .foregroundStyle(Color.vpInk) | |
| 79 | + .minimumScaleFactor(0.7) | |
| 80 | + .lineLimit(1) | |
| 81 | + Text(label.uppercased()) | |
| 82 | + .font(.vpMono(8.5)) | |
| 83 | + .tracking(0.6) | |
| 84 | + .foregroundStyle(Color.vpInk3) | |
| 85 | + } | |
| 86 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 87 | + } | |
| 88 | + | |
| 89 | + private var methodCard: some View { | |
| 90 | + VStack(alignment: .leading, spacing: 12) { | |
| 91 | + Kicker(text: "Le moteur en cinq étapes") | |
| 92 | + step(1, "Comparables", "Ventes candidates dans un rayon adaptatif (2,2 → 28 km), filtrées par type et superficie.") | |
| 93 | + step(2, "Ajustements en dollars", "Conditions de marché (indice mensuel $/m²), superficie (50 % du $/m² marginal), âge (0,5 %/an).") | |
| 94 | + step(3, "Pondération gaussienne", "Distance (σ 1 500 m), récence (σ 24 mois), similarité de superficie (σ 25 %).") | |
| 95 | + step(4, "Fusion", "65 % modèle hédonique LightGBM + 35 % médiane pondérée des comparables.") | |
| 96 | + step(5, "Confiance", "Nombre de comparables, dispersion et largeur de fourchette → indice A/B/C/D.") | |
| 97 | + } | |
| 98 | + .padding(18) | |
| 99 | + .vpCard() | |
| 100 | + } | |
| 101 | + | |
| 102 | + private func step(_ n: Int, _ title: String, _ text: String) -> some View { | |
| 103 | + HStack(alignment: .top, spacing: 12) { | |
| 104 | + Text("0\(n)") | |
| 105 | + .font(.vpMonoBold(11)) | |
| 106 | + .foregroundStyle(Color.vpGreen) | |
| 107 | + .padding(.top, 2) | |
| 108 | + VStack(alignment: .leading, spacing: 3) { | |
| 109 | + Text(title.uppercased()) | |
| 110 | + .font(.vpDisplay(13.5)) | |
| 111 | + .foregroundStyle(Color.vpInk) | |
| 112 | + Text(text) | |
| 113 | + .font(.vpBody(13)) | |
| 114 | + .foregroundStyle(Color.vpInk2) | |
| 115 | + } | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + private var complianceCard: some View { | |
| 120 | + VStack(alignment: .leading, spacing: 10) { | |
| 121 | + Kicker(text: "Données et conformité") | |
| 122 | + Text("Données sources : rôles d'évaluation foncière du Québec (MAMH, licence ouverte), 6 millésimes 2021-2026. Les renseignements des propriétaires sont caviardés à la source. Conformité Loi 25.") | |
| 123 | + .font(.vpBody(13.5)) | |
| 124 | + .foregroundStyle(Color.vpInk2) | |
| 125 | + Text("Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un évaluateur agréé (OEAQ).") | |
| 126 | + .font(.vpMono(9.5)) | |
| 127 | + .foregroundStyle(Color.vpInk3) | |
| 128 | + } | |
| 129 | + .padding(18) | |
| 130 | + .vpCard() | |
| 131 | + } | |
| 132 | +} | |
| 133 | + | |
| 134 | +#Preview { | |
| 135 | + AboutView() | |
| 136 | +} | |
added
VraiPrix/Views/EstimateLoaderView.swift
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Charge l'estimation complète d'une unité puis affiche EstimateView. | |
| 3 | +import SwiftUI | |
| 4 | + | |
| 5 | +struct EstimateLoaderView: View { | |
| 6 | + let searchResult: SearchResult | |
| 7 | + @State private var data: UnitEstimate? | |
| 8 | + @State private var errorMessage: String? | |
| 9 | + | |
| 10 | + var body: some View { | |
| 11 | + Group { | |
| 12 | + if let data { | |
| 13 | + EstimateView(data: data) | |
| 14 | + } else if let errorMessage { | |
| 15 | + VStack(spacing: 14) { | |
| 16 | + Text("!").font(.vpDisplay(40)).foregroundStyle(Color.vpLime) | |
| 17 | + Text(errorMessage) | |
| 18 | + .font(.vpBody(15)) | |
| 19 | + .foregroundStyle(Color.vpInk2) | |
| 20 | + .multilineTextAlignment(.center) | |
| 21 | + Button("Réessayer") { | |
| 22 | + self.errorMessage = nil | |
| 23 | + Task { await load() } | |
| 24 | + } | |
| 25 | + .buttonStyle(VPGhostButtonStyle()) | |
| 26 | + .frame(width: 180) | |
| 27 | + } | |
| 28 | + .padding(24) | |
| 29 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 30 | + .background(Color.vpPaper) | |
| 31 | + } else { | |
| 32 | + VStack(spacing: 14) { | |
| 33 | + ProgressView() | |
| 34 | + .controlSize(.large) | |
| 35 | + Text("Calcul de l'estimation…") | |
| 36 | + .font(.vpMono(11)) | |
| 37 | + .tracking(1.2) | |
| 38 | + .foregroundStyle(Color.vpInk2) | |
| 39 | + Text("Comparables + modèle hédonique") | |
| 40 | + .font(.vpBody(13)) | |
| 41 | + .foregroundStyle(Color.vpInk3) | |
| 42 | + } | |
| 43 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 44 | + .background(Color.vpPaper) | |
| 45 | + .task { await load() } | |
| 46 | + } | |
| 47 | + } | |
| 48 | + .navigationBarTitleDisplayMode(.inline) | |
| 49 | + } | |
| 50 | + | |
| 51 | + private func load() async { | |
| 52 | + do { | |
| 53 | + data = try await VraiPrixAPI.shared.estimate(id: searchResult.id) | |
| 54 | + } catch { | |
| 55 | + errorMessage = error.localizedDescription | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
added
VraiPrix/Views/EstimateView.swift
+493 −0
@@ -0,0 +1,493 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Résultat d'estimation — transparence totale : valeur, fourchette P10–P90, | |
| 3 | +// indice de confiance, fusion modèle/comparables, historique 2021→2026, | |
| 4 | +// caractéristiques du rôle et chaque comparable avec ses ajustements en dollars. | |
| 5 | +import SwiftUI | |
| 6 | +import Charts | |
| 7 | + | |
| 8 | +struct EstimateView: View { | |
| 9 | + let data: UnitEstimate | |
| 10 | + @State private var reportURL: URL? | |
| 11 | + @State private var loadingReport: String? | |
| 12 | + @State private var reportError: String? | |
| 13 | + | |
| 14 | + private var r: EstimateResult { data.result } | |
| 15 | + | |
| 16 | + var body: some View { | |
| 17 | + ScrollView { | |
| 18 | + VStack(alignment: .leading, spacing: 18) { | |
| 19 | + if let unit = data.unit { | |
| 20 | + addressHeader(unit) | |
| 21 | + } | |
| 22 | + estimateCard | |
| 23 | + fusionCard | |
| 24 | + if let unit = data.unit { | |
| 25 | + historyCard(unit) | |
| 26 | + specsCard(unit) | |
| 27 | + } | |
| 28 | + if !r.comps.isEmpty { | |
| 29 | + compsSection | |
| 30 | + } | |
| 31 | + if data.unit != nil { | |
| 32 | + reportsCard | |
| 33 | + } | |
| 34 | + disclaimer | |
| 35 | + } | |
| 36 | + .padding(16) | |
| 37 | + } | |
| 38 | + .background(Color.vpPaper) | |
| 39 | + .toolbar { | |
| 40 | + ToolbarItem(placement: .principal) { Wordmark(size: 18) } | |
| 41 | + } | |
| 42 | + .toolbarBackground(Color.vpPaper, for: .navigationBar) | |
| 43 | + .toolbarBackground(.visible, for: .navigationBar) | |
| 44 | + .sheet(item: $reportURL) { url in | |
| 45 | + PDFViewerView(url: url) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + // MARK: - En-tête d'adresse | |
| 50 | + | |
| 51 | + private func addressHeader(_ unit: PropertyUnit) -> some View { | |
| 52 | + VStack(alignment: .leading, spacing: 6) { | |
| 53 | + Kicker(text: PropType.label(for: unit.typeProp)) | |
| 54 | + Text(unit.adresse ?? "Adresse inconnue") | |
| 55 | + .font(.vpDisplay(24)) | |
| 56 | + .foregroundStyle(Color.vpInk) | |
| 57 | + .textCase(.uppercase) | |
| 58 | + HStack(spacing: 8) { | |
| 59 | + Text(unit.municipalite ?? "—") | |
| 60 | + .font(.vpBody(14)) | |
| 61 | + .foregroundStyle(Color.vpInk2) | |
| 62 | + if let libelle = unit.cubfLibelle { | |
| 63 | + Text("· \(libelle)") | |
| 64 | + .font(.vpBody(14)) | |
| 65 | + .foregroundStyle(Color.vpInk3) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + // MARK: - Carte estimation | |
| 72 | + | |
| 73 | + private var estimateCard: some View { | |
| 74 | + VStack(alignment: .leading, spacing: 14) { | |
| 75 | + Kicker(text: "Valeur marchande estimée") | |
| 76 | + Text(Fmt.cad(r.estimate)) | |
| 77 | + .font(.vpDisplay(44)) | |
| 78 | + .foregroundStyle(Color.vpInk) | |
| 79 | + .minimumScaleFactor(0.6) | |
| 80 | + .lineLimit(1) | |
| 81 | + | |
| 82 | + RangeBar(low: r.low, estimate: r.estimate, high: r.high) | |
| 83 | + | |
| 84 | + HStack { | |
| 85 | + VStack(alignment: .leading, spacing: 2) { | |
| 86 | + Text("BAS (P10)").font(.vpMono(9.5)).foregroundStyle(Color.vpInk3) | |
| 87 | + Text(Fmt.cad(r.low)).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk) | |
| 88 | + } | |
| 89 | + Spacer() | |
| 90 | + ConfidenceBadge(level: r.confidenceLevel, pct: r.confidencePct) | |
| 91 | + Spacer() | |
| 92 | + VStack(alignment: .trailing, spacing: 2) { | |
| 93 | + Text("HAUT (P90)").font(.vpMono(9.5)).foregroundStyle(Color.vpInk3) | |
| 94 | + Text(Fmt.cad(r.high)).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + if let unit = data.unit, let role = unit.valeurRole { | |
| 99 | + Rectangle().fill(Color.vpInk.opacity(0.15)).frame(height: 1) | |
| 100 | + HStack { | |
| 101 | + Text("Valeur au rôle 2024–2026") | |
| 102 | + .font(.vpBody(13)) | |
| 103 | + .foregroundStyle(Color.vpInk2) | |
| 104 | + Spacer() | |
| 105 | + Text(Fmt.cad(role)) | |
| 106 | + .font(.vpMonoBold(13)) | |
| 107 | + .foregroundStyle(Color.vpInk) | |
| 108 | + if role > 0 { | |
| 109 | + Text(Fmt.signedCad(r.estimate - role)) | |
| 110 | + .font(.vpMono(11)) | |
| 111 | + .foregroundStyle(r.estimate >= role ? Color.vpGreen : Color.vpInk3) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + .padding(18) | |
| 117 | + .vpCard() | |
| 118 | + } | |
| 119 | + | |
| 120 | + // MARK: - Fusion modèle + comparables | |
| 121 | + | |
| 122 | + private var fusionCard: some View { | |
| 123 | + VStack(alignment: .leading, spacing: 12) { | |
| 124 | + Kicker(text: "Comment ce chiffre est calculé") | |
| 125 | + if r.modelEstimate != nil { | |
| 126 | + fusionRow( | |
| 127 | + label: "Modèle hédonique", | |
| 128 | + sub: "LightGBM · 18 variables · pré-calculé", | |
| 129 | + value: r.modelEstimate, | |
| 130 | + weight: r.modelWeight | |
| 131 | + ) | |
| 132 | + } | |
| 133 | + fusionRow( | |
| 134 | + label: "Comparables ajustés", | |
| 135 | + sub: "\(r.nCompsUsed) ventes réelles · médiane pondérée", | |
| 136 | + value: r.compsEstimate, | |
| 137 | + weight: 1 - r.modelWeight | |
| 138 | + ) | |
| 139 | + if let disp = r.compsDispersionPct { | |
| 140 | + Text("Dispersion des comparables : \(Fmt.pct(disp))") | |
| 141 | + .font(.vpMono(10)) | |
| 142 | + .foregroundStyle(Color.vpInk3) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + .padding(18) | |
| 146 | + .vpCard() | |
| 147 | + } | |
| 148 | + | |
| 149 | + private func fusionRow(label: String, sub: String, value: Double?, weight: Double) -> some View { | |
| 150 | + HStack(alignment: .center, spacing: 12) { | |
| 151 | + Text("\(Int((weight * 100).rounded())) %") | |
| 152 | + .font(.vpDisplay(19)) | |
| 153 | + .foregroundStyle(Color.vpLime) | |
| 154 | + .frame(width: 62, alignment: .leading) | |
| 155 | + VStack(alignment: .leading, spacing: 2) { | |
| 156 | + Text(label).font(.vpDisplayMedium(15)).foregroundStyle(Color.vpInk) | |
| 157 | + Text(sub).font(.vpBody(12)).foregroundStyle(Color.vpInk3) | |
| 158 | + } | |
| 159 | + Spacer() | |
| 160 | + Text(Fmt.cad(value)) | |
| 161 | + .font(.vpMonoBold(14)) | |
| 162 | + .foregroundStyle(Color.vpInk) | |
| 163 | + } | |
| 164 | + .padding(12) | |
| 165 | + .background(RoundedRectangle(cornerRadius: 10).fill(Color.vpSurface2)) | |
| 166 | + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.vpInk.opacity(0.2), lineWidth: 1)) | |
| 167 | + } | |
| 168 | + | |
| 169 | + // MARK: - Historique 2021→2026 | |
| 170 | + | |
| 171 | + private func historyCard(_ unit: PropertyUnit) -> some View { | |
| 172 | + let points = unit.history.filter { $0.value != nil } | |
| 173 | + return VStack(alignment: .leading, spacing: 12) { | |
| 174 | + Kicker(text: "Historique des estimations") | |
| 175 | + if points.count >= 2 { | |
| 176 | + Chart(points) { p in | |
| 177 | + LineMark( | |
| 178 | + x: .value("Année", String(p.year)), | |
| 179 | + y: .value("Valeur", p.value ?? 0) | |
| 180 | + ) | |
| 181 | + .foregroundStyle(Color.vpInk) | |
| 182 | + .lineStyle(StrokeStyle(lineWidth: 2.5)) | |
| 183 | + PointMark( | |
| 184 | + x: .value("Année", String(p.year)), | |
| 185 | + y: .value("Valeur", p.value ?? 0) | |
| 186 | + ) | |
| 187 | + .foregroundStyle(p.year == 2026 ? Color.vpLime : Color.vpInk) | |
| 188 | + } | |
| 189 | + .chartYAxis { | |
| 190 | + AxisMarks(position: .leading) { v in | |
| 191 | + AxisGridLine().foregroundStyle(Color.vpInk.opacity(0.1)) | |
| 192 | + AxisValueLabel { | |
| 193 | + if let d = v.as(Double.self) { | |
| 194 | + Text(shortCad(d)).font(.vpMono(9)).foregroundStyle(Color.vpInk3) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | + .chartXAxis { | |
| 200 | + AxisMarks { _ in | |
| 201 | + AxisValueLabel().font(.vpMono(9)).foregroundStyle(Color.vpInk2) | |
| 202 | + } | |
| 203 | + } | |
| 204 | + .frame(height: 170) | |
| 205 | + | |
| 206 | + if let first = points.first?.value, let last = points.last?.value, first > 0 { | |
| 207 | + Text("Croissance \(points.first!.year)→\(points.last!.year) : \(Fmt.pct((last / first - 1) * 100))") | |
| 208 | + .font(.vpMono(10.5)) | |
| 209 | + .foregroundStyle(Color.vpGreen) | |
| 210 | + } | |
| 211 | + } else { | |
| 212 | + Text("Historique insuffisant pour cette unité.") | |
| 213 | + .font(.vpBody(13)) | |
| 214 | + .foregroundStyle(Color.vpInk3) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + .padding(18) | |
| 218 | + .vpCard() | |
| 219 | + } | |
| 220 | + | |
| 221 | + private func shortCad(_ v: Double) -> String { | |
| 222 | + if abs(v) >= 1_000_000 { | |
| 223 | + return String(format: "%.1f M$", locale: Locale(identifier: "fr_CA"), v / 1_000_000) | |
| 224 | + } | |
| 225 | + return "\(Int((v / 1000).rounded())) k$" | |
| 226 | + } | |
| 227 | + | |
| 228 | + // MARK: - Caractéristiques | |
| 229 | + | |
| 230 | + private func specsCard(_ unit: PropertyUnit) -> some View { | |
| 231 | + VStack(alignment: .leading, spacing: 12) { | |
| 232 | + Kicker(text: "La propriété selon le rôle") | |
| 233 | + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { | |
| 234 | + specCell("Année de construction", unit.anneeConstruction.map(String.init) ?? "—") | |
| 235 | + specCell("Aire des étages", Fmt.m2(unit.aireEtagesM2)) | |
| 236 | + specCell("Terrain", Fmt.m2(unit.superficieTerrainM2)) | |
| 237 | + specCell("Logements", unit.nbLogements.map(String.init) ?? "—") | |
| 238 | + specCell("Étages", unit.specs.nbEtages.map(String.init) ?? "—") | |
| 239 | + specCell("Façade du terrain", unit.specs.frontTerrainM.map { Fmt.num($0) + " m" } ?? "—") | |
| 240 | + specCell("Lien physique", unit.specs.lienPhysique ?? "—") | |
| 241 | + specCell("Genre", unit.specs.genreConstruction ?? "—") | |
| 242 | + specCell("Valeur du terrain", Fmt.cad(unit.specs.valeurTerrain)) | |
| 243 | + specCell("Valeur du bâtiment", Fmt.cad(unit.specs.valeurBatiment)) | |
| 244 | + } | |
| 245 | + } | |
| 246 | + .padding(18) | |
| 247 | + .vpCard() | |
| 248 | + } | |
| 249 | + | |
| 250 | + private func specCell(_ label: String, _ value: String) -> some View { | |
| 251 | + VStack(alignment: .leading, spacing: 3) { | |
| 252 | + Text(label.uppercased()) | |
| 253 | + .font(.vpMono(9)) | |
| 254 | + .tracking(0.6) | |
| 255 | + .foregroundStyle(Color.vpInk3) | |
| 256 | + Text(value) | |
| 257 | + .font(.vpDisplayMedium(14.5)) | |
| 258 | + .foregroundStyle(Color.vpInk) | |
| 259 | + } | |
| 260 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 261 | + } | |
| 262 | + | |
| 263 | + // MARK: - Comparables | |
| 264 | + | |
| 265 | + private var compsSection: some View { | |
| 266 | + VStack(alignment: .leading, spacing: 12) { | |
| 267 | + Kicker(text: "Ventes comparables — \(r.nCompsUsed) utilisées") | |
| 268 | + Text("Chaque ajustement est montré en dollars. Aucune boîte noire.") | |
| 269 | + .font(.vpBody(13)) | |
| 270 | + .foregroundStyle(Color.vpInk2) | |
| 271 | + ForEach(topComps) { c in | |
| 272 | + CompCard(comp: c) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + private var topComps: [AdjustedComp] { | |
| 278 | + Array(r.comps.sorted { $0.weight > $1.weight }.prefix(8)) | |
| 279 | + } | |
| 280 | + | |
| 281 | + // MARK: - Rapports PDF | |
| 282 | + | |
| 283 | + private var reportsCard: some View { | |
| 284 | + VStack(alignment: .leading, spacing: 12) { | |
| 285 | + Kicker(text: "Rapports PDF") | |
| 286 | + Button { | |
| 287 | + downloadReport(pro: false) | |
| 288 | + } label: { | |
| 289 | + labelWithSpinner("Rapport standard (3 pages)", active: loadingReport == "standard") | |
| 290 | + } | |
| 291 | + .buttonStyle(VPPrimaryButtonStyle()) | |
| 292 | + Button { | |
| 293 | + downloadReport(pro: true) | |
| 294 | + } label: { | |
| 295 | + labelWithSpinner("Rapport professionnel (6 pages)", active: loadingReport == "pro") | |
| 296 | + } | |
| 297 | + .buttonStyle(VPGhostButtonStyle()) | |
| 298 | + if let reportError { | |
| 299 | + Text(reportError) | |
| 300 | + .font(.vpBody(13)) | |
| 301 | + .foregroundStyle(Color.vpGreen) | |
| 302 | + } | |
| 303 | + } | |
| 304 | + .padding(18) | |
| 305 | + .vpCard() | |
| 306 | + } | |
| 307 | + | |
| 308 | + private func labelWithSpinner(_ title: String, active: Bool) -> some View { | |
| 309 | + HStack(spacing: 10) { | |
| 310 | + if active { ProgressView().controlSize(.small) } | |
| 311 | + Text(active ? "Génération…" : title) | |
| 312 | + } | |
| 313 | + } | |
| 314 | + | |
| 315 | + private func downloadReport(pro: Bool) { | |
| 316 | + guard let unit = data.unit, loadingReport == nil else { return } | |
| 317 | + loadingReport = pro ? "pro" : "standard" | |
| 318 | + reportError = nil | |
| 319 | + Task { | |
| 320 | + defer { loadingReport = nil } | |
| 321 | + do { | |
| 322 | + let path = pro ? "/api/report/pro" : "/api/report" | |
| 323 | + let name = "vrai-prix-\(pro ? "pro-" : "")\(unit.id).pdf" | |
| 324 | + reportURL = try await VraiPrixAPI.shared.downloadReport( | |
| 325 | + path: path, | |
| 326 | + query: [URLQueryItem(name: "id", value: unit.id)], | |
| 327 | + fileName: name | |
| 328 | + ) | |
| 329 | + } catch { | |
| 330 | + reportError = error.localizedDescription | |
| 331 | + } | |
| 332 | + } | |
| 333 | + } | |
| 334 | + | |
| 335 | + private var disclaimer: some View { | |
| 336 | + Text("Estimation statistique à titre indicatif, fondée sur les rôles d'évaluation foncière du Québec (MAMH, licence ouverte) et 745 119 ventes réelles. Ne remplace pas une évaluation par un évaluateur agréé (OEAQ).") | |
| 337 | + .font(.vpMono(9.5)) | |
| 338 | + .foregroundStyle(Color.vpInk3) | |
| 339 | + .padding(.top, 2) | |
| 340 | + .padding(.bottom, 12) | |
| 341 | + } | |
| 342 | +} | |
| 343 | + | |
| 344 | +// URL identifiable pour .sheet(item:) | |
| 345 | +extension URL: @retroactive Identifiable { | |
| 346 | + public var id: String { absoluteString } | |
| 347 | +} | |
| 348 | + | |
| 349 | +// MARK: - Fourchette P10–P90 | |
| 350 | + | |
| 351 | +struct RangeBar: View { | |
| 352 | + let low: Double | |
| 353 | + let estimate: Double | |
| 354 | + let high: Double | |
| 355 | + | |
| 356 | + var body: some View { | |
| 357 | + GeometryReader { geo in | |
| 358 | + let w = geo.size.width | |
| 359 | + let span = max(high - low, 1) | |
| 360 | + let x = min(max((estimate - low) / span, 0), 1) * w | |
| 361 | + ZStack(alignment: .leading) { | |
| 362 | + Capsule() | |
| 363 | + .fill(Color.vpLimeSoft) | |
| 364 | + .frame(height: 10) | |
| 365 | + .overlay(Capsule().stroke(Color.vpInk, lineWidth: 1.5)) | |
| 366 | + Circle() | |
| 367 | + .fill(Color.vpLime) | |
| 368 | + .overlay(Circle().stroke(Color.vpInk, lineWidth: 2)) | |
| 369 | + .frame(width: 20, height: 20) | |
| 370 | + .offset(x: min(max(x - 10, 0), w - 20)) | |
| 371 | + } | |
| 372 | + } | |
| 373 | + .frame(height: 20) | |
| 374 | + } | |
| 375 | +} | |
| 376 | + | |
| 377 | +// MARK: - Badge de confiance A–D | |
| 378 | + | |
| 379 | +struct ConfidenceBadge: View { | |
| 380 | + let level: String | |
| 381 | + let pct: Double | |
| 382 | + | |
| 383 | + var body: some View { | |
| 384 | + HStack(spacing: 8) { | |
| 385 | + Text(level) | |
| 386 | + .font(.vpDisplay(20)) | |
| 387 | + .foregroundStyle(Color.vpLime) | |
| 388 | + .frame(width: 38, height: 38) | |
| 389 | + .background(RoundedRectangle(cornerRadius: 9).fill(Color.vpInk)) | |
| 390 | + .rotationEffect(.degrees(-2)) | |
| 391 | + VStack(alignment: .leading, spacing: 1) { | |
| 392 | + Text("CONFIANCE") | |
| 393 | + .font(.vpMono(8.5)) | |
| 394 | + .tracking(1) | |
| 395 | + .foregroundStyle(Color.vpInk3) | |
| 396 | + Text("\(Int(pct.rounded())) %") | |
| 397 | + .font(.vpDisplayMedium(15)) | |
| 398 | + .foregroundStyle(Color.vpInk) | |
| 399 | + } | |
| 400 | + } | |
| 401 | + } | |
| 402 | +} | |
| 403 | + | |
| 404 | +// MARK: - Carte comparable | |
| 405 | + | |
| 406 | +struct CompCard: View { | |
| 407 | + let comp: AdjustedComp | |
| 408 | + @State private var expanded = false | |
| 409 | + | |
| 410 | + var body: some View { | |
| 411 | + VStack(alignment: .leading, spacing: 10) { | |
| 412 | + HStack(alignment: .firstTextBaseline) { | |
| 413 | + VStack(alignment: .leading, spacing: 2) { | |
| 414 | + Text(comp.street ?? "Adresse non divulguée") | |
| 415 | + .font(.vpDisplayMedium(15)) | |
| 416 | + .foregroundStyle(Color.vpInk) | |
| 417 | + Text("\(comp.city ?? "—") · vendu en \(Fmt.monthYear(comp.date))") | |
| 418 | + .font(.vpBody(12)) | |
| 419 | + .foregroundStyle(Color.vpInk2) | |
| 420 | + } | |
| 421 | + Spacer() | |
| 422 | + VStack(alignment: .trailing, spacing: 2) { | |
| 423 | + Text(Fmt.cad(comp.amount)) | |
| 424 | + .font(.vpMonoBold(14)) | |
| 425 | + .foregroundStyle(Color.vpInk) | |
| 426 | + Text("PRIX PAYÉ") | |
| 427 | + .font(.vpMono(8)) | |
| 428 | + .tracking(0.8) | |
| 429 | + .foregroundStyle(Color.vpInk3) | |
| 430 | + } | |
| 431 | + } | |
| 432 | + | |
| 433 | + HStack(spacing: 8) { | |
| 434 | + chip(Fmt.distance(comp.distanceM)) | |
| 435 | + if let area = comp.floorArea { chip(Fmt.m2(area)) } | |
| 436 | + if let year = comp.yearBuilt { chip(String(year)) } | |
| 437 | + chip("poids \(Fmt.pct(comp.weight * 100, decimals: 0))") | |
| 438 | + } | |
| 439 | + | |
| 440 | + if expanded { | |
| 441 | + VStack(spacing: 6) { | |
| 442 | + adjRow("Conditions de marché", comp.adjTime) | |
| 443 | + adjRow("Superficie", comp.adjArea) | |
| 444 | + adjRow("Âge du bâtiment", comp.adjAge) | |
| 445 | + Rectangle().fill(Color.vpInk.opacity(0.2)).frame(height: 1) | |
| 446 | + HStack { | |
| 447 | + Text("Prix ajusté") | |
| 448 | + .font(.vpDisplayMedium(13.5)) | |
| 449 | + .foregroundStyle(Color.vpInk) | |
| 450 | + Spacer() | |
| 451 | + Text(Fmt.cad(comp.adjustedPrice)) | |
| 452 | + .font(.vpMonoBold(14)) | |
| 453 | + .foregroundStyle(Color.vpGreen) | |
| 454 | + } | |
| 455 | + } | |
| 456 | + .padding(10) | |
| 457 | + .background(RoundedRectangle(cornerRadius: 10).fill(Color.vpSurface2)) | |
| 458 | + } | |
| 459 | + | |
| 460 | + Button { | |
| 461 | + withAnimation(.snappy(duration: 0.2)) { expanded.toggle() } | |
| 462 | + } label: { | |
| 463 | + Text(expanded ? "Masquer les ajustements" : "Voir les ajustements en $") | |
| 464 | + .font(.vpMonoBold(10.5)) | |
| 465 | + .tracking(0.6) | |
| 466 | + .foregroundStyle(Color.vpGreen) | |
| 467 | + } | |
| 468 | + .buttonStyle(.plain) | |
| 469 | + } | |
| 470 | + .padding(14) | |
| 471 | + .vpCard(corner: 12) | |
| 472 | + } | |
| 473 | + | |
| 474 | + private func chip(_ text: String) -> some View { | |
| 475 | + Text(text) | |
| 476 | + .font(.vpMono(10)) | |
| 477 | + .foregroundStyle(Color.vpInk2) | |
| 478 | + .padding(.horizontal, 9) | |
| 479 | + .padding(.vertical, 4) | |
| 480 | + .background(Capsule().fill(Color.vpSurface)) | |
| 481 | + .overlay(Capsule().stroke(Color.vpInk.opacity(0.35), lineWidth: 1)) | |
| 482 | + } | |
| 483 | + | |
| 484 | + private func adjRow(_ label: String, _ value: Double) -> some View { | |
| 485 | + HStack { | |
| 486 | + Text(label).font(.vpBody(13)).foregroundStyle(Color.vpInk2) | |
| 487 | + Spacer() | |
| 488 | + Text(Fmt.signedCad(value)) | |
| 489 | + .font(.vpMono(12.5)) | |
| 490 | + .foregroundStyle(abs(value) < 0.5 ? Color.vpInk3 : Color.vpInk) | |
| 491 | + } | |
| 492 | + } | |
| 493 | +} | |
added
VraiPrix/Views/ManualView.swift
+158 −0
@@ -0,0 +1,158 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Estimation manuelle : municipalité + type + caractéristiques facultatives, | |
| 3 | +// sans adresse précise — le moteur travaille autour du centre de la municipalité. | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +struct ManualView: View { | |
| 7 | + @State private var municipality = "" | |
| 8 | + @State private var type: PropType = .unifamilial | |
| 9 | + @State private var floorArea = "" | |
| 10 | + @State private var yearBuilt = "" | |
| 11 | + @State private var landArea = "" | |
| 12 | + @State private var loading = false | |
| 13 | + @State private var errorMessage: String? | |
| 14 | + @State private var result: UnitEstimate? | |
| 15 | + @State private var showResult = false | |
| 16 | + | |
| 17 | + var body: some View { | |
| 18 | + NavigationStack { | |
| 19 | + ScrollView { | |
| 20 | + VStack(alignment: .leading, spacing: 18) { | |
| 21 | + VStack(alignment: .leading, spacing: 8) { | |
| 22 | + Text("Estimation manuelle") | |
| 23 | + .font(.vpDisplay(26)) | |
| 24 | + .foregroundStyle(Color.vpInk) | |
| 25 | + .textCase(.uppercase) | |
| 26 | + Text("Pas d'adresse exacte ? Décrivez la propriété — le moteur s'appuie sur les ventes réelles de la municipalité.") | |
| 27 | + .font(.vpBody(14.5)) | |
| 28 | + .foregroundStyle(Color.vpInk2) | |
| 29 | + } | |
| 30 | + .padding(.top, 6) | |
| 31 | + | |
| 32 | + VStack(alignment: .leading, spacing: 16) { | |
| 33 | + field(label: "Municipalité — requis") { | |
| 34 | + TextField("Ex. : Québec, Lévis, Sherbrooke…", text: $municipality) | |
| 35 | + .vpField() | |
| 36 | + .autocorrectionDisabled() | |
| 37 | + } | |
| 38 | + | |
| 39 | + VStack(alignment: .leading, spacing: 8) { | |
| 40 | + Kicker(text: "Type de propriété — requis") | |
| 41 | + typeGrid | |
| 42 | + } | |
| 43 | + | |
| 44 | + field(label: "Aire des étages (m²) — facultatif") { | |
| 45 | + TextField("Ex. : 150", text: $floorArea) | |
| 46 | + .vpField() | |
| 47 | + .keyboardType(.decimalPad) | |
| 48 | + } | |
| 49 | + field(label: "Année de construction — facultatif") { | |
| 50 | + TextField("Ex. : 1985", text: $yearBuilt) | |
| 51 | + .vpField() | |
| 52 | + .keyboardType(.numberPad) | |
| 53 | + } | |
| 54 | + field(label: "Superficie du terrain (m²) — facultatif") { | |
| 55 | + TextField("Ex. : 500", text: $landArea) | |
| 56 | + .vpField() | |
| 57 | + .keyboardType(.decimalPad) | |
| 58 | + } | |
| 59 | + | |
| 60 | + Button { | |
| 61 | + submit() | |
| 62 | + } label: { | |
| 63 | + HStack(spacing: 10) { | |
| 64 | + if loading { ProgressView().controlSize(.small) } | |
| 65 | + Text(loading ? "Calcul…" : "Estimer") | |
| 66 | + } | |
| 67 | + } | |
| 68 | + .buttonStyle(VPPrimaryButtonStyle()) | |
| 69 | + .disabled(loading || municipality.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 70 | + .opacity(municipality.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1) | |
| 71 | + | |
| 72 | + if let errorMessage { | |
| 73 | + Text(errorMessage) | |
| 74 | + .font(.vpBody(13.5)) | |
| 75 | + .foregroundStyle(Color.vpGreen) | |
| 76 | + } | |
| 77 | + } | |
| 78 | + .padding(18) | |
| 79 | + .vpCard() | |
| 80 | + | |
| 81 | + Text("L'estimation manuelle est moins précise qu'une estimation par adresse : elle n'utilise pas le modèle hédonique pré-calculé, seulement les comparables.") | |
| 82 | + .font(.vpMono(9.5)) | |
| 83 | + .foregroundStyle(Color.vpInk3) | |
| 84 | + } | |
| 85 | + .padding(16) | |
| 86 | + } | |
| 87 | + .background(Color.vpPaper) | |
| 88 | + .toolbar { | |
| 89 | + ToolbarItem(placement: .principal) { Wordmark(size: 20) } | |
| 90 | + } | |
| 91 | + .navigationBarTitleDisplayMode(.inline) | |
| 92 | + .toolbarBackground(Color.vpPaper, for: .navigationBar) | |
| 93 | + .toolbarBackground(.visible, for: .navigationBar) | |
| 94 | + .navigationDestination(isPresented: $showResult) { | |
| 95 | + if let result { | |
| 96 | + EstimateView(data: result) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + private var typeGrid: some View { | |
| 103 | + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { | |
| 104 | + ForEach(PropType.allCases) { t in | |
| 105 | + Button { | |
| 106 | + type = t | |
| 107 | + } label: { | |
| 108 | + Text(t.label) | |
| 109 | + .font(.vpDisplayMedium(12.5)) | |
| 110 | + .foregroundStyle(type == t ? Color.vpLime : Color.vpInk) | |
| 111 | + .frame(maxWidth: .infinity) | |
| 112 | + .padding(.vertical, 10) | |
| 113 | + .background( | |
| 114 | + RoundedRectangle(cornerRadius: 10) | |
| 115 | + .fill(type == t ? Color.vpInk : Color.vpSurface2) | |
| 116 | + ) | |
| 117 | + .overlay( | |
| 118 | + RoundedRectangle(cornerRadius: 10) | |
| 119 | + .stroke(type == t ? Color.vpInk : Color.vpInk.opacity(0.3), lineWidth: 1.5) | |
| 120 | + ) | |
| 121 | + } | |
| 122 | + .buttonStyle(.plain) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + private func field(label: String, @ViewBuilder content: () -> some View) -> some View { | |
| 128 | + VStack(alignment: .leading, spacing: 8) { | |
| 129 | + Kicker(text: label) | |
| 130 | + content() | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + private func submit() { | |
| 135 | + guard !loading else { return } | |
| 136 | + loading = true | |
| 137 | + errorMessage = nil | |
| 138 | + Task { | |
| 139 | + defer { loading = false } | |
| 140 | + do { | |
| 141 | + result = try await VraiPrixAPI.shared.estimateManual( | |
| 142 | + municipality: municipality.trimmingCharacters(in: .whitespaces), | |
| 143 | + typeProp: type.rawValue, | |
| 144 | + floorArea: Double(floorArea.replacingOccurrences(of: ",", with: ".")), | |
| 145 | + yearBuilt: Int(yearBuilt), | |
| 146 | + landArea: Double(landArea.replacingOccurrences(of: ",", with: ".")) | |
| 147 | + ) | |
| 148 | + showResult = true | |
| 149 | + } catch { | |
| 150 | + errorMessage = error.localizedDescription | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +#Preview { | |
| 157 | + ManualView() | |
| 158 | +} | |
added
VraiPrix/Views/MapSearchView.swift
+198 −0
@@ -0,0 +1,198 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Recherche par carte : propriétés avoisinantes (MapKit), géolocalisation, | |
| 3 | +// fiche d'aperçu → estimation complète. Même API /api/nearby que le site. | |
| 4 | +import SwiftUI | |
| 5 | +import MapKit | |
| 6 | +import CoreLocation | |
| 7 | + | |
| 8 | +struct MapSearchView: View { | |
| 9 | + private static let montreal = MKCoordinateRegion( | |
| 10 | + center: CLLocationCoordinate2D(latitude: 45.5421, longitude: -73.6646), | |
| 11 | + span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.016) | |
| 12 | + ) | |
| 13 | + /// Au-delà de cette étendue, on ne charge pas (trop dézoomé). | |
| 14 | + private static let maxSpanLat = 0.045 | |
| 15 | + | |
| 16 | + @State private var position: MapCameraPosition = .region(Self.montreal) | |
| 17 | + @State private var units: [NearbyUnit] = [] | |
| 18 | + @State private var selected: NearbyUnit? | |
| 19 | + @State private var zoomedOut = false | |
| 20 | + @State private var fetchTask: Task<Void, Never>? | |
| 21 | + @State private var locationManager = CLLocationManager() | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + NavigationStack { | |
| 25 | + ZStack(alignment: .bottom) { | |
| 26 | + map | |
| 27 | + overlayChip | |
| 28 | + if let selected { | |
| 29 | + selectionCard(selected) | |
| 30 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 31 | + } | |
| 32 | + } | |
| 33 | + .toolbar { | |
| 34 | + ToolbarItem(placement: .principal) { Wordmark(size: 20) } | |
| 35 | + ToolbarItem(placement: .topBarTrailing) { | |
| 36 | + Button { | |
| 37 | + locate() | |
| 38 | + } label: { | |
| 39 | + Image(systemName: "location.fill") | |
| 40 | + .font(.system(size: 14, weight: .bold)) | |
| 41 | + .foregroundStyle(Color.vpLime) | |
| 42 | + .padding(8) | |
| 43 | + .background(Circle().fill(Color.vpInk)) | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + .navigationBarTitleDisplayMode(.inline) | |
| 48 | + .toolbarBackground(Color.vpPaper, for: .navigationBar) | |
| 49 | + .toolbarBackground(.visible, for: .navigationBar) | |
| 50 | + .navigationDestination(for: SearchResult.self) { r in | |
| 51 | + EstimateLoaderView(searchResult: r) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + // MARK: - Carte | |
| 57 | + | |
| 58 | + private var map: some View { | |
| 59 | + Map(position: $position) { | |
| 60 | + UserAnnotation() | |
| 61 | + ForEach(units) { u in | |
| 62 | + Annotation("", coordinate: CLLocationCoordinate2D(latitude: u.lat, longitude: u.lng)) { | |
| 63 | + Button { | |
| 64 | + withAnimation(.snappy(duration: 0.2)) { selected = u } | |
| 65 | + } label: { | |
| 66 | + Circle() | |
| 67 | + .fill(selected?.id == u.id ? Color.vpLime : Color.vpInk) | |
| 68 | + .frame(width: 15, height: 15) | |
| 69 | + .overlay(Circle().stroke(selected?.id == u.id ? Color.vpInk : Color.vpLime, lineWidth: 2.5)) | |
| 70 | + .shadow(color: Color.vpInk.opacity(0.35), radius: 2, y: 1) | |
| 71 | + } | |
| 72 | + .buttonStyle(.plain) | |
| 73 | + .accessibilityIdentifier("vp-dot") | |
| 74 | + .accessibilityLabel(u.adresse ?? "Propriété") | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + .mapStyle(.standard(pointsOfInterest: .excludingAll)) | |
| 79 | + .onMapCameraChange(frequency: .onEnd) { context in | |
| 80 | + reload(region: context.region) | |
| 81 | + } | |
| 82 | + .task { reload(region: Self.montreal) } // chargement initial | |
| 83 | + .ignoresSafeArea(edges: .bottom) | |
| 84 | + } | |
| 85 | + | |
| 86 | + private var overlayChip: some View { | |
| 87 | + VStack { | |
| 88 | + Text(zoomedOut | |
| 89 | + ? "Zoomez pour voir les propriétés" | |
| 90 | + : "\(units.count) propriétés · touchez un point") | |
| 91 | + .font(.vpMonoBold(10.5)) | |
| 92 | + .tracking(0.6) | |
| 93 | + .foregroundStyle(Color.vpInk) | |
| 94 | + .padding(.horizontal, 14) | |
| 95 | + .padding(.vertical, 8) | |
| 96 | + .background(Capsule().fill(Color.vpSurface)) | |
| 97 | + .overlay(Capsule().stroke(Color.vpInk, lineWidth: 1.5)) | |
| 98 | + Spacer() | |
| 99 | + } | |
| 100 | + .padding(.top, 12) | |
| 101 | + } | |
| 102 | + | |
| 103 | + // MARK: - Fiche de sélection | |
| 104 | + | |
| 105 | + private func selectionCard(_ u: NearbyUnit) -> some View { | |
| 106 | + VStack(alignment: .leading, spacing: 10) { | |
| 107 | + HStack(alignment: .firstTextBaseline) { | |
| 108 | + VStack(alignment: .leading, spacing: 3) { | |
| 109 | + Text(fullAddress(u)) | |
| 110 | + .font(.vpDisplay(17)) | |
| 111 | + .foregroundStyle(Color.vpInk) | |
| 112 | + .textCase(.uppercase) | |
| 113 | + Text("\(u.municipalite ?? "—") · \(PropType.label(for: u.typeProp))") | |
| 114 | + .font(.vpBody(13)) | |
| 115 | + .foregroundStyle(Color.vpInk2) | |
| 116 | + } | |
| 117 | + Spacer() | |
| 118 | + Button { | |
| 119 | + withAnimation(.snappy(duration: 0.2)) { selected = nil } | |
| 120 | + } label: { | |
| 121 | + Image(systemName: "xmark") | |
| 122 | + .font(.system(size: 12, weight: .bold)) | |
| 123 | + .foregroundStyle(Color.vpInk) | |
| 124 | + .padding(9) | |
| 125 | + .background(Circle().stroke(Color.vpInk, lineWidth: 1.5)) | |
| 126 | + } | |
| 127 | + .buttonStyle(.plain) | |
| 128 | + } | |
| 129 | + HStack(alignment: .lastTextBaseline) { | |
| 130 | + VStack(alignment: .leading, spacing: 2) { | |
| 131 | + Text("ESTIMATION 2026") | |
| 132 | + .font(.vpMono(9)) | |
| 133 | + .tracking(1) | |
| 134 | + .foregroundStyle(Color.vpInk3) | |
| 135 | + Text(Fmt.cad(u.est2026)) | |
| 136 | + .font(.vpDisplay(26)) | |
| 137 | + .foregroundStyle(Color.vpInk) | |
| 138 | + } | |
| 139 | + Spacer() | |
| 140 | + NavigationLink(value: u.asSearchResult) { | |
| 141 | + Text("Estimer en détail →") | |
| 142 | + .font(.vpDisplay(14)) | |
| 143 | + .foregroundStyle(Color.vpLime) | |
| 144 | + .padding(.horizontal, 16) | |
| 145 | + .padding(.vertical, 11) | |
| 146 | + .background(Capsule().fill(Color.vpInk)) | |
| 147 | + } | |
| 148 | + .buttonStyle(.plain) | |
| 149 | + } | |
| 150 | + } | |
| 151 | + .padding(16) | |
| 152 | + .vpCard() | |
| 153 | + .padding(.horizontal, 14) | |
| 154 | + .padding(.bottom, 14) | |
| 155 | + } | |
| 156 | + | |
| 157 | + private func fullAddress(_ u: NearbyUnit) -> String { | |
| 158 | + var s = u.adresse ?? "Adresse inconnue" | |
| 159 | + if let apt = u.apt, !apt.isEmpty { s += ", app. \(apt)" } | |
| 160 | + return s | |
| 161 | + } | |
| 162 | + | |
| 163 | + // MARK: - Chargement | |
| 164 | + | |
| 165 | + private func reload(region: MKCoordinateRegion) { | |
| 166 | + guard region.span.latitudeDelta <= Self.maxSpanLat else { | |
| 167 | + zoomedOut = true | |
| 168 | + units = [] | |
| 169 | + return | |
| 170 | + } | |
| 171 | + zoomedOut = false | |
| 172 | + fetchTask?.cancel() | |
| 173 | + fetchTask = Task { | |
| 174 | + do { | |
| 175 | + let found = try await VraiPrixAPI.shared.nearby( | |
| 176 | + lat: region.center.latitude, | |
| 177 | + lng: region.center.longitude, | |
| 178 | + halfLat: region.span.latitudeDelta / 2, | |
| 179 | + halfLng: region.span.longitudeDelta / 2 | |
| 180 | + ) | |
| 181 | + if !Task.isCancelled { units = found } | |
| 182 | + } catch { | |
| 183 | + /* requête annulée ou réseau — silencieux */ | |
| 184 | + } | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + private func locate() { | |
| 189 | + locationManager.requestWhenInUseAuthorization() | |
| 190 | + withAnimation { | |
| 191 | + position = .userLocation(fallback: .region(Self.montreal)) | |
| 192 | + } | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +#Preview { | |
| 197 | + MapSearchView() | |
| 198 | +} | |
added
VraiPrix/Views/PDFViewerView.swift
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Visionneuse des rapports PDF (pdfkit côté serveur, 100 % vectoriels) + partage. | |
| 3 | +import SwiftUI | |
| 4 | +import PDFKit | |
| 5 | + | |
| 6 | +struct PDFViewerView: View { | |
| 7 | + let url: URL | |
| 8 | + @Environment(\.dismiss) private var dismiss | |
| 9 | + | |
| 10 | + var body: some View { | |
| 11 | + NavigationStack { | |
| 12 | + PDFKitView(url: url) | |
| 13 | + .ignoresSafeArea(edges: .bottom) | |
| 14 | + .toolbar { | |
| 15 | + ToolbarItem(placement: .principal) { Wordmark(size: 17) } | |
| 16 | + ToolbarItem(placement: .topBarLeading) { | |
| 17 | + Button("Fermer") { dismiss() } | |
| 18 | + .font(.vpDisplayMedium(14)) | |
| 19 | + .foregroundStyle(Color.vpInk) | |
| 20 | + } | |
| 21 | + ToolbarItem(placement: .topBarTrailing) { | |
| 22 | + ShareLink(item: url) { | |
| 23 | + Image(systemName: "square.and.arrow.up") | |
| 24 | + .foregroundStyle(Color.vpInk) | |
| 25 | + } | |
| 26 | + } | |
| 27 | + } | |
| 28 | + .toolbarBackground(Color.vpPaper, for: .navigationBar) | |
| 29 | + .toolbarBackground(.visible, for: .navigationBar) | |
| 30 | + } | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +private struct PDFKitView: UIViewRepresentable { | |
| 35 | + let url: URL | |
| 36 | + | |
| 37 | + func makeUIView(context: Context) -> PDFView { | |
| 38 | + let view = PDFView() | |
| 39 | + view.autoScales = true | |
| 40 | + view.backgroundColor = UIColor(Color.vpPaper) | |
| 41 | + view.document = PDFDocument(url: url) | |
| 42 | + return view | |
| 43 | + } | |
| 44 | + | |
| 45 | + func updateUIView(_ view: PDFView, context: Context) { | |
| 46 | + if view.document?.documentURL != url { | |
| 47 | + view.document = PDFDocument(url: url) | |
| 48 | + } | |
| 49 | + } | |
| 50 | +} | |
added
VraiPrix/Views/RootView.swift
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Racine : trois onglets — Estimer (recherche), Manuelle, À propos. | |
| 3 | +import SwiftUI | |
| 4 | + | |
| 5 | +struct RootView: View { | |
| 6 | + var body: some View { | |
| 7 | + TabView { | |
| 8 | + SearchView() | |
| 9 | + .tabItem { Label("Estimer", systemImage: "magnifyingglass") } | |
| 10 | + MapSearchView() | |
| 11 | + .tabItem { Label("Carte", systemImage: "map") } | |
| 12 | + ManualView() | |
| 13 | + .tabItem { Label("Manuelle", systemImage: "slider.horizontal.3") } | |
| 14 | + AboutView() | |
| 15 | + .tabItem { Label("À propos", systemImage: "text.book.closed") } | |
| 16 | + } | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +#Preview { | |
| 21 | + RootView() | |
| 22 | +} | |
added
VraiPrix/Views/SearchView.swift
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Recherche d'adresse (FTS5 côté serveur) avec autocomplétion débouncée, | |
| 3 | +// puis navigation vers l'estimation complète. | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +struct SearchView: View { | |
| 7 | + @State private var query = "" | |
| 8 | + @State private var results: [SearchResult] = [] | |
| 9 | + @State private var searching = false | |
| 10 | + @State private var errorMessage: String? | |
| 11 | + | |
| 12 | + var body: some View { | |
| 13 | + NavigationStack { | |
| 14 | + ScrollView { | |
| 15 | + VStack(alignment: .leading, spacing: 18) { | |
| 16 | + header | |
| 17 | + | |
| 18 | + VStack(alignment: .leading, spacing: 10) { | |
| 19 | + Kicker(text: "Adresse au Québec") | |
| 20 | + TextField("Ex. : 10005 rue Verville, Montréal", text: $query) | |
| 21 | + .vpField() | |
| 22 | + .autocorrectionDisabled() | |
| 23 | + .textInputAutocapitalization(.never) | |
| 24 | + .submitLabel(.search) | |
| 25 | + Text("3 747 008 unités d'évaluation · 745 119 ventes réelles") | |
| 26 | + .font(.vpMono(10.5)) | |
| 27 | + .foregroundStyle(Color.vpInk3) | |
| 28 | + } | |
| 29 | + .padding(16) | |
| 30 | + .vpCard() | |
| 31 | + | |
| 32 | + if searching { | |
| 33 | + HStack(spacing: 10) { | |
| 34 | + ProgressView() | |
| 35 | + Text("Recherche…") | |
| 36 | + .font(.vpBody(14)) | |
| 37 | + .foregroundStyle(Color.vpInk2) | |
| 38 | + } | |
| 39 | + .frame(maxWidth: .infinity) | |
| 40 | + .padding(.top, 8) | |
| 41 | + } | |
| 42 | + | |
| 43 | + if let errorMessage { | |
| 44 | + Text(errorMessage) | |
| 45 | + .font(.vpBody(14)) | |
| 46 | + .foregroundStyle(Color.vpGreen) | |
| 47 | + } | |
| 48 | + | |
| 49 | + if !results.isEmpty { | |
| 50 | + VStack(spacing: 0) { | |
| 51 | + ForEach(Array(results.enumerated()), id: \.element.id) { index, r in | |
| 52 | + NavigationLink(value: r) { | |
| 53 | + resultRow(index: index, r: r) | |
| 54 | + } | |
| 55 | + .buttonStyle(.plain) | |
| 56 | + if r.id != results.last?.id { | |
| 57 | + Rectangle() | |
| 58 | + .fill(Color.vpInk.opacity(0.15)) | |
| 59 | + .frame(height: 1) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + } | |
| 63 | + .vpCard() | |
| 64 | + } else if query.trimmingCharacters(in: .whitespaces).count >= 3, !searching { | |
| 65 | + Text("Aucun résultat — précisez le numéro civique et la rue.") | |
| 66 | + .font(.vpBody(14)) | |
| 67 | + .foregroundStyle(Color.vpInk3) | |
| 68 | + } | |
| 69 | + | |
| 70 | + disclaimer | |
| 71 | + } | |
| 72 | + .padding(16) | |
| 73 | + } | |
| 74 | + .background(Color.vpPaper) | |
| 75 | + .navigationDestination(for: SearchResult.self) { r in | |
| 76 | + EstimateLoaderView(searchResult: r) | |
| 77 | + } | |
| 78 | + .toolbar { | |
| 79 | + ToolbarItem(placement: .principal) { Wordmark(size: 20) } | |
| 80 | + } | |
| 81 | + .navigationBarTitleDisplayMode(.inline) | |
| 82 | + .toolbarBackground(Color.vpPaper, for: .navigationBar) | |
| 83 | + .toolbarBackground(.visible, for: .navigationBar) | |
| 84 | + } | |
| 85 | + .task(id: query) { | |
| 86 | + await runSearch() | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + private var header: some View { | |
| 91 | + VStack(alignment: .leading, spacing: 8) { | |
| 92 | + Text("La vraie valeur de votre propriété") | |
| 93 | + .font(.vpDisplay(28)) | |
| 94 | + .foregroundStyle(Color.vpInk) | |
| 95 | + .textCase(.uppercase) | |
| 96 | + .lineSpacing(2) | |
| 97 | + Text("Sans boîte noire — chaque comparable, chaque ajustement en dollars, la fourchette et l'indice de confiance.") | |
| 98 | + .font(.vpBody(14.5)) | |
| 99 | + .foregroundStyle(Color.vpInk2) | |
| 100 | + } | |
| 101 | + .padding(.top, 6) | |
| 102 | + } | |
| 103 | + | |
| 104 | + private func resultRow(index: Int, r: SearchResult) -> some View { | |
| 105 | + HStack(alignment: .firstTextBaseline, spacing: 12) { | |
| 106 | + Text(String(format: "%02d", index + 1)) | |
| 107 | + .font(.vpMonoBold(11)) | |
| 108 | + .foregroundStyle(Color.vpGreen) | |
| 109 | + VStack(alignment: .leading, spacing: 3) { | |
| 110 | + Text(fullAddress(r)) | |
| 111 | + .font(.vpDisplayMedium(15.5)) | |
| 112 | + .foregroundStyle(Color.vpInk) | |
| 113 | + .multilineTextAlignment(.leading) | |
| 114 | + HStack(spacing: 8) { | |
| 115 | + Text(r.municipalite ?? "—") | |
| 116 | + .font(.vpBody(12.5)) | |
| 117 | + .foregroundStyle(Color.vpInk2) | |
| 118 | + Text(PropType.label(for: r.typeProp).uppercased()) | |
| 119 | + .font(.vpMono(9.5)) | |
| 120 | + .tracking(0.8) | |
| 121 | + .foregroundStyle(Color.vpGreen) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + Spacer() | |
| 125 | + Image(systemName: "arrow.right") | |
| 126 | + .font(.system(size: 13, weight: .bold)) | |
| 127 | + .foregroundStyle(Color.vpInk) | |
| 128 | + } | |
| 129 | + .padding(.horizontal, 14) | |
| 130 | + .padding(.vertical, 12) | |
| 131 | + .contentShape(Rectangle()) | |
| 132 | + } | |
| 133 | + | |
| 134 | + private func fullAddress(_ r: SearchResult) -> String { | |
| 135 | + var s = r.adresse ?? "—" | |
| 136 | + if let apt = r.apt, !apt.isEmpty { s += ", app. \(apt)" } | |
| 137 | + return s | |
| 138 | + } | |
| 139 | + | |
| 140 | + private var disclaimer: some View { | |
| 141 | + Text("Estimations statistiques à titre indicatif — ne remplacent pas une évaluation par un évaluateur agréé (OEAQ).") | |
| 142 | + .font(.vpMono(9.5)) | |
| 143 | + .foregroundStyle(Color.vpInk3) | |
| 144 | + .padding(.top, 4) | |
| 145 | + } | |
| 146 | + | |
| 147 | + private func runSearch() async { | |
| 148 | + let q = query.trimmingCharacters(in: .whitespaces) | |
| 149 | + guard q.count >= 3 else { | |
| 150 | + results = [] | |
| 151 | + searching = false | |
| 152 | + errorMessage = nil | |
| 153 | + return | |
| 154 | + } | |
| 155 | + // debounce 300 ms — la tâche est annulée si la saisie continue | |
| 156 | + try? await Task.sleep(for: .milliseconds(300)) | |
| 157 | + guard !Task.isCancelled else { return } | |
| 158 | + searching = true | |
| 159 | + errorMessage = nil | |
| 160 | + do { | |
| 161 | + let found = try await VraiPrixAPI.shared.search(q) | |
| 162 | + guard !Task.isCancelled else { return } | |
| 163 | + results = found | |
| 164 | + } catch is CancellationError { | |
| 165 | + return | |
| 166 | + } catch { | |
| 167 | + if !Task.isCancelled { errorMessage = error.localizedDescription } | |
| 168 | + } | |
| 169 | + searching = false | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +#Preview { | |
| 174 | + SearchView() | |
| 175 | +} | |
added
VraiPrix/VraiPrixApp.swift
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Vrai-Prix iOS — la vraie valeur de votre propriété, sans boîte noire. | |
| 3 | +// App native SwiftUI consommant l'API de production https://www.vrai-prix.com | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +@main | |
| 7 | +struct VraiPrixApp: App { | |
| 8 | + var body: some Scene { | |
| 9 | + WindowGroup { | |
| 10 | + RootView() | |
| 11 | + .tint(.vpInk) | |
| 12 | + } | |
| 13 | + } | |
| 14 | +} | |
added
VraiPrixUITests/SmokeTests.swift
+95 −0
@@ -0,0 +1,95 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Test de fumée bout en bout : recherche d'adresse réelle → estimation complète | |
| 3 | +// contre l'API de production. Capture des écrans dans /tmp/vp-ui-*.png. | |
| 4 | +import XCTest | |
| 5 | + | |
| 6 | +final class SmokeTests: XCTestCase { | |
| 7 | + | |
| 8 | + private func snap(_ app: XCUIApplication, _ name: String) { | |
| 9 | + let png = XCUIScreen.main.screenshot().pngRepresentation | |
| 10 | + try? png.write(to: URL(fileURLWithPath: "/tmp/vp-ui-\(name).png")) | |
| 11 | + } | |
| 12 | + | |
| 13 | + func testSearchToEstimate() throws { | |
| 14 | + let app = XCUIApplication() | |
| 15 | + app.launch() | |
| 16 | + | |
| 17 | + // 1. Recherche | |
| 18 | + let field = app.textFields.firstMatch | |
| 19 | + XCTAssertTrue(field.waitForExistence(timeout: 10), "champ de recherche absent") | |
| 20 | + field.tap() | |
| 21 | + field.typeText("10005 rue verville") | |
| 22 | + | |
| 23 | + // 2. Premier résultat (FTS5 côté serveur) | |
| 24 | + let result = app.buttons.containing( | |
| 25 | + NSPredicate(format: "label CONTAINS[c] 'VERVILLE'") | |
| 26 | + ).firstMatch | |
| 27 | + XCTAssertTrue(result.waitForExistence(timeout: 20), "aucun résultat de recherche") | |
| 28 | + snap(app, "1-recherche") | |
| 29 | + result.tap() | |
| 30 | + | |
| 31 | + // 3. Estimation complète (comparables + modèle) | |
| 32 | + let kicker = app.staticTexts["VALEUR MARCHANDE ESTIMÉE"] | |
| 33 | + XCTAssertTrue(kicker.waitForExistence(timeout: 45), "l'estimation n'est pas apparue") | |
| 34 | + snap(app, "2-estimation") | |
| 35 | + | |
| 36 | + // 4. Défiler vers les comparables et déplier les ajustements | |
| 37 | + app.swipeUp(velocity: .fast) | |
| 38 | + app.swipeUp(velocity: .fast) | |
| 39 | + snap(app, "3-details") | |
| 40 | + let toggle = app.buttons.containing( | |
| 41 | + NSPredicate(format: "label CONTAINS[c] 'ajustements'") | |
| 42 | + ).firstMatch | |
| 43 | + if toggle.waitForExistence(timeout: 5) { | |
| 44 | + toggle.tap() | |
| 45 | + snap(app, "4-comparable") | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + func testMapNearby() throws { | |
| 50 | + let app = XCUIApplication() | |
| 51 | + app.launch() | |
| 52 | + | |
| 53 | + app.tabBars.buttons["Carte"].tap() | |
| 54 | + | |
| 55 | + // la puce affiche « N propriétés · touchez un point » une fois chargé | |
| 56 | + let chip = app.staticTexts.containing( | |
| 57 | + NSPredicate(format: "label CONTAINS 'touchez'") | |
| 58 | + ).firstMatch | |
| 59 | + XCTAssertTrue(chip.waitForExistence(timeout: 20), "puce de compteur absente") | |
| 60 | + let loaded = NSPredicate(format: "label CONTAINS 'touchez' AND NOT label BEGINSWITH '0 '") | |
| 61 | + expectation(for: loaded, evaluatedWith: chip) | |
| 62 | + waitForExpectations(timeout: 30) | |
| 63 | + snap(app, "6-carte") | |
| 64 | + | |
| 65 | + // toucher un point (identifiant d'accessibilité) → fiche → estimation complète | |
| 66 | + let dot = app.buttons.matching(identifier: "vp-dot").firstMatch | |
| 67 | + XCTAssertTrue(dot.waitForExistence(timeout: 10), "aucun point sur la carte") | |
| 68 | + dot.tap() | |
| 69 | + let cta = app.buttons["Estimer en détail →"] | |
| 70 | + XCTAssertTrue(cta.waitForExistence(timeout: 8), "fiche de sélection absente") | |
| 71 | + snap(app, "7-carte-fiche") | |
| 72 | + cta.tap() | |
| 73 | + let kicker = app.staticTexts["VALEUR MARCHANDE ESTIMÉE"] | |
| 74 | + XCTAssertTrue(kicker.waitForExistence(timeout: 45), "estimation depuis la carte absente") | |
| 75 | + snap(app, "8-carte-estimation") | |
| 76 | + } | |
| 77 | + | |
| 78 | + func testManualEstimate() throws { | |
| 79 | + let app = XCUIApplication() | |
| 80 | + app.launch() | |
| 81 | + | |
| 82 | + app.tabBars.buttons["Manuelle"].tap() | |
| 83 | + let field = app.textFields.firstMatch | |
| 84 | + XCTAssertTrue(field.waitForExistence(timeout: 10)) | |
| 85 | + field.tap() | |
| 86 | + field.typeText("Lévis") | |
| 87 | + app.scrollViews.buttons["Plex"].firstMatch.tap() | |
| 88 | + app.swipeUp() // replie le clavier pour dégager le bouton | |
| 89 | + app.scrollViews.buttons["Estimer"].firstMatch.tap() | |
| 90 | + | |
| 91 | + let kicker = app.staticTexts["VALEUR MARCHANDE ESTIMÉE"] | |
| 92 | + XCTAssertTrue(kicker.waitForExistence(timeout: 45), "l'estimation manuelle n'est pas apparue") | |
| 93 | + snap(app, "5-manuelle") | |
| 94 | + } | |
| 95 | +} | |
added
project.yml
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +# Projet iOS Vrai-Prix — généré avec XcodeGen (xcodegen generate) | |
| 3 | +name: VraiPrix | |
| 4 | +options: | |
| 5 | + bundleIdPrefix: ai.spboucher | |
| 6 | + deploymentTarget: | |
| 7 | + iOS: "17.0" | |
| 8 | + createIntermediateGroups: true | |
| 9 | +targets: | |
| 10 | + VraiPrix: | |
| 11 | + type: application | |
| 12 | + platform: iOS | |
| 13 | + sources: | |
| 14 | + - VraiPrix | |
| 15 | + settings: | |
| 16 | + base: | |
| 17 | + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.vraiprix | |
| 18 | + MARKETING_VERSION: 1.1.0 | |
| 19 | + CURRENT_PROJECT_VERSION: 1 | |
| 20 | + TARGETED_DEVICE_FAMILY: "1,2" | |
| 21 | + SWIFT_VERSION: "5.9" | |
| 22 | + CODE_SIGN_STYLE: Automatic | |
| 23 | + DEVELOPMENT_TEAM: 3YM54G49SN | |
| 24 | + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon | |
| 25 | + INFOPLIST_KEY_CFBundleDisplayName: Vrai-Prix | |
| 26 | + scheme: | |
| 27 | + testTargets: | |
| 28 | + - VraiPrixUITests | |
| 29 | + info: | |
| 30 | + path: VraiPrix/Info.plist | |
| 31 | + properties: | |
| 32 | + CFBundleDisplayName: Vrai-Prix | |
| 33 | + CFBundleName: VraiPrix | |
| 34 | + CFBundleShortVersionString: 1.1.0 | |
| 35 | + CFBundleVersion: "1" | |
| 36 | + UILaunchScreen: | |
| 37 | + UIColorName: PaperBackground | |
| 38 | + UISupportedInterfaceOrientations: | |
| 39 | + - UIInterfaceOrientationPortrait | |
| 40 | + UISupportedInterfaceOrientations~ipad: | |
| 41 | + - UIInterfaceOrientationPortrait | |
| 42 | + - UIInterfaceOrientationPortraitUpsideDown | |
| 43 | + - UIInterfaceOrientationLandscapeLeft | |
| 44 | + - UIInterfaceOrientationLandscapeRight | |
| 45 | + UIAppFonts: | |
| 46 | + - SpaceGrotesk-Bold.ttf | |
| 47 | + - SpaceGrotesk-Medium.ttf | |
| 48 | + - Inter-Regular.ttf | |
| 49 | + - JetBrainsMono-Regular.ttf | |
| 50 | + - JetBrainsMono-Bold.ttf | |
| 51 | + ITSAppUsesNonExemptEncryption: false | |
| 52 | + NSLocationWhenInUseUsageDescription: >- | |
| 53 | + Votre position sert uniquement à centrer la carte sur votre quartier | |
| 54 | + pour afficher les propriétés avoisinantes. Elle n'est jamais transmise. | |
| 55 | + VraiPrixUITests: | |
| 56 | + type: bundle.ui-testing | |
| 57 | + platform: iOS | |
| 58 | + sources: | |
| 59 | + - VraiPrixUITests | |
| 60 | + dependencies: | |
| 61 | + - target: VraiPrix | |
| 62 | + settings: | |
| 63 | + base: | |
| 64 | + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.vraiprix.uitests | |
| 65 | + SWIFT_VERSION: "5.9" | |
| 66 | + CODE_SIGN_STYLE: Automatic | |
added
scripts/make-icon.swift
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Génère l'icône iOS 1024×1024 à partir du wordmark Vrai-Prix | |
| 3 | +// (papier #f5f3ee, encre #141814, accent #ff5148, Space Grotesk Bold), | |
| 4 | +// fidèle au logo du site : « Vrai » en encre + « Prix » en boîte encre | |
| 5 | +// pivotée de -2° avec texte accent et ombre décalée dure. | |
| 6 | +// Usage : swift make-icon.swift <fonts-dir> <sortie.png> | |
| 7 | +import AppKit | |
| 8 | +import CoreText | |
| 9 | + | |
| 10 | +let args = CommandLine.arguments | |
| 11 | +guard args.count == 3 else { | |
| 12 | + FileHandle.standardError.write("usage: swift make-icon.swift <fonts-dir> <out.png>\n".data(using: .utf8)!) | |
| 13 | + exit(1) | |
| 14 | +} | |
| 15 | +let fontsDir = args[1] | |
| 16 | +let outPath = args[2] | |
| 17 | + | |
| 18 | +// Enregistrer Space Grotesk Bold | |
| 19 | +let fontURL = URL(fileURLWithPath: "\(fontsDir)/SpaceGrotesk-Bold.ttf") | |
| 20 | +CTFontManagerRegisterFontsForURL(fontURL as CFURL, .process, nil) | |
| 21 | + | |
| 22 | +func color(_ hex: UInt32) -> NSColor { | |
| 23 | + NSColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, | |
| 24 | + green: CGFloat((hex >> 8) & 0xff) / 255, | |
| 25 | + blue: CGFloat(hex & 0xff) / 255, alpha: 1) | |
| 26 | +} | |
| 27 | +let paper = color(0xF5F3EE) | |
| 28 | +let ink = color(0x141814) | |
| 29 | +let lime = color(0xFF5148) | |
| 30 | + | |
| 31 | +let S: CGFloat = 1024 | |
| 32 | +// contexte bitmap exact 1024×1024, sans alpha (exigence App Store) | |
| 33 | +guard let ctx = CGContext( | |
| 34 | + data: nil, width: Int(S), height: Int(S), bitsPerComponent: 8, bytesPerRow: 0, | |
| 35 | + space: CGColorSpace(name: CGColorSpace.sRGB)!, | |
| 36 | + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue | |
| 37 | +) else { exit(1) } | |
| 38 | +NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false) | |
| 39 | + | |
| 40 | +// fond papier plein cadre (iOS applique lui-même le masque arrondi) | |
| 41 | +paper.setFill() | |
| 42 | +ctx.fill(CGRect(x: 0, y: 0, width: S, height: S)) | |
| 43 | + | |
| 44 | +// grain très léger façon papier (points encre à faible alpha, déterministe) | |
| 45 | +ctx.setFillColor(ink.withAlphaComponent(0.028).cgColor) | |
| 46 | +var seed: UInt64 = 0x5EED_CAFE | |
| 47 | +func rnd() -> CGFloat { | |
| 48 | + seed = seed &* 6364136223846793005 &+ 1442695040888963407 | |
| 49 | + return CGFloat((seed >> 33) % 10_000) / 10_000 | |
| 50 | +} | |
| 51 | +for _ in 0..<2600 { | |
| 52 | + let x = rnd() * S, y = rnd() * S, r = 1.0 + rnd() * 1.6 | |
| 53 | + ctx.fillEllipse(in: CGRect(x: x, y: y, width: r, height: r)) | |
| 54 | +} | |
| 55 | + | |
| 56 | +let font = NSFont(name: "SpaceGrotesk-Bold", size: 252)! | |
| 57 | +func attrString(_ s: String, _ c: NSColor) -> NSAttributedString { | |
| 58 | + NSAttributedString(string: s, attributes: [.font: font, .foregroundColor: c, .kern: -10]) | |
| 59 | +} | |
| 60 | + | |
| 61 | +// mesures | |
| 62 | +let vrai = attrString("Vrai", ink) | |
| 63 | +let prix = attrString("Prix", lime) | |
| 64 | +let vraiSize = vrai.size() | |
| 65 | +let prixSize = prix.size() | |
| 66 | + | |
| 67 | +// boîte « Prix » : encre, coins arrondis, pivotée -2°, ombre décalée dure | |
| 68 | +let padX: CGFloat = 46 | |
| 69 | +let padY: CGFloat = 6 | |
| 70 | +let boxW = prixSize.width + padX * 2 | |
| 71 | +let boxH = prixSize.height + padY * 2 | |
| 72 | +let gap: CGFloat = 34 | |
| 73 | + | |
| 74 | +let totalH = vraiSize.height + gap + boxH | |
| 75 | +// repère depuis le haut (coordonnées AppKit = origine en bas), remonté pour dégager la micro-étiquette | |
| 76 | +let topY = (S - totalH) / 2 + totalH + 56 | |
| 77 | + | |
| 78 | +// « Vrai » centré en haut du bloc | |
| 79 | +let vraiOrigin = CGPoint(x: (S - vraiSize.width) / 2, y: topY - vraiSize.height) | |
| 80 | +vrai.draw(at: vraiOrigin) | |
| 81 | + | |
| 82 | +// boîte pivotée autour de son centre | |
| 83 | +let boxCenter = CGPoint(x: S / 2, y: topY - vraiSize.height - gap - boxH / 2) | |
| 84 | +ctx.saveGState() | |
| 85 | +ctx.translateBy(x: boxCenter.x, y: boxCenter.y) | |
| 86 | +ctx.rotate(by: -2 * .pi / 180) | |
| 87 | + | |
| 88 | +// ombre décalée dure (signature du design) | |
| 89 | +let boxRect = CGRect(x: -boxW / 2, y: -boxH / 2, width: boxW, height: boxH) | |
| 90 | +let shadowPath = CGPath(roundedRect: boxRect.offsetBy(dx: 14, dy: -14), cornerWidth: 28, cornerHeight: 28, transform: nil) | |
| 91 | +ctx.addPath(shadowPath) | |
| 92 | +ctx.setFillColor(ink.withAlphaComponent(0.22).cgColor) | |
| 93 | +ctx.fillPath() | |
| 94 | + | |
| 95 | +let boxPath = CGPath(roundedRect: boxRect, cornerWidth: 28, cornerHeight: 28, transform: nil) | |
| 96 | +ctx.addPath(boxPath) | |
| 97 | +ctx.setFillColor(ink.cgColor) | |
| 98 | +ctx.fillPath() | |
| 99 | + | |
| 100 | +prix.draw(at: CGPoint(x: -prixSize.width / 2, y: -prixSize.height / 2 + 6)) | |
| 101 | +ctx.restoreGState() | |
| 102 | + | |
| 103 | +// micro-étiquette mono en bas : trait + « QUÉBEC » | |
| 104 | +let microFont = NSFont(name: "SpaceGrotesk-Bold", size: 44)! | |
| 105 | +let micro = NSAttributedString(string: "QUÉBEC", attributes: [ | |
| 106 | + .font: microFont, .foregroundColor: color(0x9E2A25), .kern: 14, | |
| 107 | +]) | |
| 108 | +let mSize = micro.size() | |
| 109 | +micro.draw(at: CGPoint(x: (S - mSize.width) / 2, y: 118)) | |
| 110 | +let lineW: CGFloat = 56 | |
| 111 | +ctx.setFillColor(color(0x9E2A25).cgColor) | |
| 112 | +ctx.fill(CGRect(x: (S - lineW) / 2, y: 96, width: lineW, height: 6)) | |
| 113 | + | |
| 114 | +guard let cgImage = ctx.makeImage() else { exit(1) } | |
| 115 | +let rep = NSBitmapImageRep(cgImage: cgImage) | |
| 116 | +guard let png = rep.representation(using: .png, properties: [:]) else { exit(1) } | |
| 117 | +try! png.write(to: URL(fileURLWithPath: outPath)) | |
| 118 | +print("OK \(outPath)") | |
| 119 | ||