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%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Estimation manuelle : municipalité + type + caractéristiques facultatives,3// sans adresse précise — le moteur travaille autour du centre de la municipalité.4import SwiftUI56struct ManualView: View {7 @State private var municipality = ""8 @State private var type: PropType = .unifamilial9 @State private var floorArea = ""10 @State private var yearBuilt = ""11 @State private var landArea = ""12 @State private var loading = false13 @State private var errorMessage: String?14 @State private var result: UnitEstimate?15 @State private var showResult = false1617 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)3132 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 }3839 VStack(alignment: .leading, spacing: 8) {40 Kicker(text: "Type de propriété — requis")41 typeGrid42 }4344 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 }5960 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)7172 if let errorMessage {73 Text(errorMessage)74 .font(.vpBody(13.5))75 .foregroundStyle(Color.vpGreen)76 }77 }78 .padding(18)79 .vpCard()8081 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 }101102 private var typeGrid: some View {103 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {104 ForEach(PropType.allCases) { t in105 Button {106 type = t107 } 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 }126127 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 }133134 private func submit() {135 guard !loading else { return }136 loading = true137 errorMessage = nil138 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 = true149 } catch {150 errorMessage = error.localizedDescription151 }152 }153 }154}155156#Preview {157 ManualView()158}159