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%
6.5 KB · 159 lines swift
Raw Blame History
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