SPB Git

spb/lou-ka-ios Public

Lou·Ka iOS — app SwiftUI native de l'agrégateur de logements du Québec : filtres avancés, carte, stats, et mode Découverte (swipe) avec recommandation on-device

Swift 100%
12.0 KB · 278 lines swift
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// StatsView.swift : portrait du marché — tuiles KPI, histogramme des loyers,5//   palmarès villes / types, offre (inclusions), baisses de prix récentes6//   Dataviz : séries uniques → une seule teinte (vert), texte en encre,7//   étiquettes directes sélectives, barres ancrées à la base, bouts arrondis.8// -----------------------------------------------------------------------------9import SwiftUI1011struct StatsView: View {12    @State private var stats: DetailedStats?13    @State private var failed = false1415    var body: some View {16        NavigationStack {17            ScrollView {18                VStack(alignment: .leading, spacing: 24) {19                    if let s = stats {20                        kpiTiles(s)21                        histogramCard(s)22                        groupCard(title: "Par région", groups: s.byRegion ?? [], unit: "annonces")23                        groupCard(title: "Villes les plus actives", groups: Array((s.byCity ?? []).prefix(8)), unit: "annonces")24                        groupCard(title: "Par taille de logement", groups: s.byType ?? [], unit: "annonces")25                        offreCard(s)26                        baissesCard(s)27                    } else if failed {28                        errorView29                    } else {30                        ProgressView("Chargement…")31                            .frame(maxWidth: .infinity)32                            .padding(.vertical, 80)33                    }34                }35                .padding(16)36                .padding(.bottom, 24)37            }38            .background(LK.paper)39            .navigationTitle("Le marché en chiffres")40            .navigationBarTitleDisplayMode(.inline)41            .refreshable { await load() }42            .task { await load() }43        }44    }4546    private func load() async {47        failed = false48        do {49            stats = try await API.detailedStats()50        } catch {51            if stats == nil { failed = true }52        }53    }5455    private var errorView: some View {56        VStack(spacing: 10) {57            Image(systemName: "wifi.exclamationmark").font(.system(size: 28))58            Text("Impossible de charger les statistiques.")59            Button("Réessayer") { Task { await load() } }60                .buttonStyle(.borderedProminent)61        }62        .foregroundStyle(LK.ink2)63        .frame(maxWidth: .infinity)64        .padding(.vertical, 60)65    }6667    // MARK: tuiles KPI6869    private func kpiTiles(_ s: DetailedStats) -> some View {70        let t = s.totals71        var tiles: [(String, String)] = []72        if let v = t?.total { tiles.append((Fmt.int(v), "annonces actives")) }73        if let v = t?.median { tiles.append((Fmt.price(v.rounded()), "loyer médian")) }74        if let v = t?.avg { tiles.append((Fmt.price(v.rounded()), "loyer moyen")) }75        if let v = t?.dispoNow { tiles.append((Fmt.int(v), "libres maintenant")) }76        if let v = t?.sources { tiles.append((Fmt.int(v), "sources actives")) }77        if let v = t?.cities { tiles.append((Fmt.int(v), "villes couvertes")) }78        return LazyVGrid(columns: [GridItem(.adaptive(minimum: 105), spacing: 10)], spacing: 10) {79            ForEach(tiles, id: \.1) { value, label in80                VStack(alignment: .leading, spacing: 3) {81                    Text(value)82                        .font(LKFont.display(21, .bold))83                        .kerning(-0.5)84                        .lineLimit(1)85                        .minimumScaleFactor(0.6)86                    Text(label.uppercased())87                        .font(LKFont.mono(8.5, .medium))88                        .kerning(0.4)89                        .foregroundStyle(LK.ink3)90                        .lineLimit(2)91                }92                .padding(12)93                .frame(maxWidth: .infinity, minHeight: 68, alignment: .topLeading)94                .background(LK.surface)95                .clipShape(RoundedRectangle(cornerRadius: 8))96                .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.ink, lineWidth: 1.5))97            }98        }99        .padding(.top, 8)100    }101102    // MARK: histogramme des loyers (série unique — teinte verte, base ancrée)103104    @ViewBuilder105    private func histogramCard(_ s: DetailedStats) -> some View {106        if let buckets = s.histogram, !buckets.isEmpty {107            let maxCount = buckets.map(\.count).max() ?? 1108            card(title: "Distribution des loyers") {109                VStack(spacing: 6) {110                    HStack(alignment: .bottom, spacing: 2) {111                        ForEach(buckets, id: \.self) { b in112                            VStack(spacing: 3) {113                                // étiquette directe : seulement le pic (sélectif)114                                if b.count == maxCount {115                                    Text(Fmt.int(b.count))116                                        .font(LKFont.mono(9.5, .medium))117                                        .foregroundStyle(LK.ink2)118                                        .fixedSize()119                                }120                                UnevenRoundedRectangle(topLeadingRadius: 3, topTrailingRadius: 3)121                                    .fill(LK.green)122                                    .frame(height: max(3, 110 * CGFloat(b.count) / CGFloat(maxCount)))123                            }124                            .frame(maxWidth: .infinity, alignment: .bottom)125                        }126                    }127                    .frame(height: 132, alignment: .bottom)128                    Rectangle().fill(LK.line).frame(height: 1)129                    HStack {130                        Text(axisLabel(buckets.first))131                        Spacer()132                        Text("loyer mensuel")133                        Spacer()134                        Text(axisLabel(buckets.last, last: true))135                    }136                    .font(LKFont.mono(9.5))137                    .foregroundStyle(LK.ink3)138                }139            }140        }141    }142143    private func axisLabel(_ b: DetailedStats.Bucket?, last: Bool = false) -> String {144        guard let b else { return "" }145        if last, b.hi == nil { return "\(Int(b.lo)) $ +" }146        return "\(Int(b.lo)) $"147    }148149    // MARK: palmarès (barres horizontales, série unique)150151    @ViewBuilder152    private func groupCard(title: String, groups: [DetailedStats.Group], unit: String) -> some View {153        if !groups.isEmpty {154            let maxCount = groups.map(\.count).max() ?? 1155            card(title: title) {156                VStack(spacing: 10) {157                    ForEach(groups, id: \.key) { g in158                        VStack(alignment: .leading, spacing: 3) {159                            HStack(alignment: .firstTextBaseline) {160                                Text(g.key.isEmpty ? "—" : g.key)161                                    .font(.system(size: 13, weight: .semibold))162                                    .lineLimit(1)163                                Spacer()164                                Text(Fmt.int(g.count))165                                    .font(LKFont.mono(11.5, .bold))166                                if let avg = g.avgPrice {167                                    Text("· moy \(Fmt.price(avg.rounded()))")168                                        .font(.system(size: 11))169                                        .foregroundStyle(LK.ink3)170                                }171                            }172                            GeometryReader { geo in173                                ZStack(alignment: .leading) {174                                    Capsule().fill(LK.line.opacity(0.5))175                                    Capsule()176                                        .fill(LK.green)177                                        .frame(width: max(6, geo.size.width * CGFloat(g.count) / CGFloat(maxCount)))178                                }179                            }180                            .frame(height: 7)181                        }182                    }183                }184            }185        }186    }187188    // MARK: offre (pourcentages d'inclusions)189190    @ViewBuilder191    private func offreCard(_ s: DetailedStats) -> some View {192        if let o = s.offre {193            let rows: [(String, Double)] = [194                o.chauffagePct.map { ("Chauffage inclus", $0) },195                o.electricitePct.map { ("Électricité incluse", $0) },196                o.internetPct.map { ("Internet inclus", $0) },197                o.stationnementPct.map { ("Stationnement", $0) },198                o.climPct.map { ("Climatisation", $0) },199                o.balconPct.map { ("Balcon", $0) },200                o.furnishedPct.map { ("Meublé", $0) },201                o.petsOuiPct.map { ("Animaux acceptés", $0) },202            ].compactMap { $0 }203            if !rows.isEmpty {204                card(title: "Ce que l'offre inclut") {205                    VStack(spacing: 9) {206                        ForEach(rows, id: \.0) { label, pct in207                            HStack(spacing: 10) {208                                Text(label)209                                    .font(.system(size: 13, weight: .medium))210                                    .frame(width: 150, alignment: .leading)211                                GeometryReader { geo in212                                    ZStack(alignment: .leading) {213                                        Capsule().fill(LK.line.opacity(0.5))214                                        Capsule()215                                            .fill(LK.green)216                                            .frame(width: max(4, geo.size.width * min(1, pct / 100)))217                                    }218                                }219                                .frame(height: 7)220                                Text("\(Int(pct.rounded())) %")221                                    .font(LKFont.mono(11.5, .bold))222                                    .frame(width: 42, alignment: .trailing)223                            }224                        }225                    }226                }227            }228        }229    }230231    // MARK: baisses de prix232233    @ViewBuilder234    private func baissesCard(_ s: DetailedStats) -> some View {235        if let baisses = s.baisses, !baisses.isEmpty {236            card(title: "Baisses de prix récentes") {237                VStack(spacing: 0) {238                    ForEach(Array(baisses.prefix(6).enumerated()), id: \.offset) { i, b in239                        HStack(spacing: 10) {240                            VStack(alignment: .leading, spacing: 1) {241                                Text(b.title)242                                    .font(.system(size: 13, weight: .semibold))243                                    .lineLimit(1)244                                Text(b.city)245                                    .font(.system(size: 11))246                                    .foregroundStyle(LK.ink3)247                            }248                            Spacer()249                            VStack(alignment: .trailing, spacing: 1) {250                                Text("\(Fmt.price(b.avant))\(Fmt.price(b.apres))")251                                    .font(LKFont.mono(11, .medium))252                                Text("−\(Int(abs(b.pct).rounded())) %")253                                    .font(.system(size: 11, weight: .bold))254                                    .foregroundStyle(LK.green)255                            }256                        }257                        .padding(.vertical, 8)258                        if i < min(baisses.count, 6) - 1 { Divider() }259                    }260                }261            }262        }263    }264265    // MARK: conteneur de carte266267    private func card(title: String, @ViewBuilder content: () -> some View) -> some View {268        VStack(alignment: .leading, spacing: 14) {269            Kicker(text: title)270            content()271        }272        .padding(15)273        .frame(maxWidth: .infinity, alignment: .leading)274        .lkCard()275        .padding(.trailing, 5)276    }277}278