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%
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