SPB Git

spb/vrai-prix-ios Public

App iOS native de Vrai-Prix — estimation immobilière transparente pour le Québec (SwiftUI, MapKit, Swift Charts)

Swift 100%

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>
Simon-Pierre Boucher committed 4 h ago (Aug 11, 2026)

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 +![Swift](https://img.shields.io/badge/SwiftUI-iOS_17%2B-ff5148?style=flat-square&logo=swift&logoColor=white)
11 +![Version](https://img.shields.io/badge/version-1.1.0-9e2a25?style=flat-square)
12 +![TestFlight](https://img.shields.io/badge/TestFlight-1.1.0_(1)-141814?style=flat-square&logo=apple)
13 +![Tests](https://img.shields.io/badge/tests_UI-3%2F3-141814?style=flat-square)
14 +![XcodeGen](https://img.shields.io/badge/projet-XcodeGen-141814?style=flat-square)
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