v3.0.0 — rendu LÉGENDAIRE des pages d'univers (Explorer) : bande héros teintée à l'accent + logo en filigrane (UniverseHeroBand), pilule live à chiffres roulants, pouls de l'univers (total/affichés/villes), chips de villes filtrantes en un tap (fetch city=), mise en page MAGAZINE quand l'univers a des photos (VITRINE pleine largeur « À la une » avec prix sur photo → carrousel « En vedette » → mosaïque 2 colonnes) avec repli liste éditoriale (Job-Ka/Trouve-Ka), kaScrollPop partout, en-têtes de section mono ; pilotage debug ka.debug.universe pour ouvrir un univers direct — nouveau fichier UniverseShowcase.swift ; validé Lou-Ka (magazine) + Job-Ka (liste), 12 tests verts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 changed files +362 −12
added
KA/Features/UniverseShowcase.swift
+268 −0
@@ -0,0 +1,268 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// UniverseShowcase.swift — le rendu LÉGENDAIRE d'un univers (onglet Explorer) : | ||||
| 3 | +// bande héros teintée à l'accent avec logo en filigrane, pouls de l'univers | ||||
| 4 | +// (compteurs roulants), villes rapides filtrantes, VITRINE immersive du | ||||
| 5 | +// premier item, carrousel « En vedette » et mosaïque magazine 2 colonnes. | ||||
| 6 | +// S'adapte : univers sans photos (Job·Ka, Trouve·Ka) → liste éditoriale. | ||||
| 7 | +import SwiftUI | ||||
| 8 | + | ||||
| 9 | +// MARK: - Bande héros : gradient accent + logo en filigrane autour du SiteHero | ||||
| 10 | + | ||||
| 11 | +struct UniverseHeroBand<Content: View>: View { | ||||
| 12 | + let universe: Universe | ||||
| 13 | + @ViewBuilder var content: Content | ||||
| 14 | + @Environment(\.colorScheme) private var scheme | ||||
| 15 | + | ||||
| 16 | + var body: some View { | ||||
| 17 | + ZStack(alignment: .topTrailing) { | ||||
| 18 | + RoundedRectangle(cornerRadius: 18, style: .continuous) | ||||
| 19 | + .fill(LinearGradient( | ||||
| 20 | + colors: [universe.accent.opacity(scheme == .dark ? 0.22 : 0.16), | ||||
| 21 | + universe.accent.opacity(0.03)], | ||||
| 22 | + startPoint: .topTrailing, endPoint: .bottomLeading)) | ||||
| 23 | + KALogo(universe: universe, size: 150) | ||||
| 24 | + .opacity(scheme == .dark ? 0.10 : 0.07) | ||||
| 25 | + .rotationEffect(.degrees(8)) | ||||
| 26 | + .offset(x: 34, y: -26) | ||||
| 27 | + .allowsHitTesting(false) | ||||
| 28 | + .accessibilityHidden(true) | ||||
| 29 | + content | ||||
| 30 | + .padding(14) | ||||
| 31 | + } | ||||
| 32 | + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) | ||||
| 33 | + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) | ||||
| 34 | + .strokeBorder(universe.accent.opacity(0.25), lineWidth: 1)) | ||||
| 35 | + } | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +// MARK: - En-tête de section éditorial (carré accent + mono uppercase) | ||||
| 39 | + | ||||
| 40 | +struct UniverseSectionHeader: View { | ||||
| 41 | + let title: String | ||||
| 42 | + let accent: Color | ||||
| 43 | + var detail: String? = nil | ||||
| 44 | + @Environment(\.colorScheme) private var scheme | ||||
| 45 | + | ||||
| 46 | + var body: some View { | ||||
| 47 | + HStack(spacing: 8) { | ||||
| 48 | + Rectangle().fill(accent).frame(width: 8, height: 8) | ||||
| 49 | + Text(title.uppercased()) | ||||
| 50 | + .font(KAFont.mono(10)) | ||||
| 51 | + .kerning(1.0) | ||||
| 52 | + .foregroundStyle(KATheme.ink(scheme)) | ||||
| 53 | + if let d = detail { | ||||
| 54 | + Text(d) | ||||
| 55 | + .font(KAFont.mono(9, bold: false)) | ||||
| 56 | + .foregroundStyle(KATheme.ink2(scheme)) | ||||
| 57 | + } | ||||
| 58 | + Spacer() | ||||
| 59 | + } | ||||
| 60 | + .padding(.top, 6) | ||||
| 61 | + .accessibilityElement(children: .combine) | ||||
| 62 | + .accessibilityAddTraits(.isHeader) | ||||
| 63 | + } | ||||
| 64 | +} | ||||
| 65 | + | ||||
| 66 | +// MARK: - Le pouls de l'univers (tuiles mono, compteurs roulants) | ||||
| 67 | + | ||||
| 68 | +struct UniverseStatStrip: View { | ||||
| 69 | + let universe: Universe | ||||
| 70 | + let liveTotal: Int? | ||||
| 71 | + let shown: Int | ||||
| 72 | + let cityCount: Int | ||||
| 73 | + @Environment(\.colorScheme) private var scheme | ||||
| 74 | + | ||||
| 75 | + var body: some View { | ||||
| 76 | + HStack(spacing: 10) { | ||||
| 77 | + if let n = liveTotal { | ||||
| 78 | + tile { KACountUp(value: n, font: KAFont.display(17), color: universe.accent) } | ||||
| 79 | + label: { universe.unit } | ||||
| 80 | + } | ||||
| 81 | + tile { Text("\(shown)").font(KAFont.display(17)).foregroundStyle(KATheme.ink(scheme)) } | ||||
| 82 | + label: { "affichés" } | ||||
| 83 | + if cityCount > 1 { | ||||
| 84 | + tile { Text("\(cityCount)").font(KAFont.display(17)).foregroundStyle(KATheme.ink(scheme)) } | ||||
| 85 | + label: { "villes" } | ||||
| 86 | + } | ||||
| 87 | + Spacer(minLength: 0) | ||||
| 88 | + } | ||||
| 89 | + } | ||||
| 90 | + | ||||
| 91 | + private func tile(@ViewBuilder _ value: () -> some View, label: () -> String) -> some View { | ||||
| 92 | + VStack(alignment: .leading, spacing: 1) { | ||||
| 93 | + value() | ||||
| 94 | + Text(label().uppercased()) | ||||
| 95 | + .font(KAFont.mono(8)) | ||||
| 96 | + .foregroundStyle(KATheme.ink2(scheme)) | ||||
| 97 | + } | ||||
| 98 | + .padding(.horizontal, 12).padding(.vertical, 8) | ||||
| 99 | + .kaCard(accent: universe.accent) | ||||
| 100 | + .accessibilityElement(children: .combine) | ||||
| 101 | + } | ||||
| 102 | +} | ||||
| 103 | + | ||||
| 104 | +// MARK: - Villes rapides (filtre en un tap) | ||||
| 105 | + | ||||
| 106 | +struct UniverseCityChips: View { | ||||
| 107 | + let cities: [String] | ||||
| 108 | + @Binding var selected: String? | ||||
| 109 | + let accent: Color | ||||
| 110 | + var onChange: () -> Void | ||||
| 111 | + @Environment(\.colorScheme) private var scheme | ||||
| 112 | + | ||||
| 113 | + var body: some View { | ||||
| 114 | + ScrollView(.horizontal, showsIndicators: false) { | ||||
| 115 | + HStack(spacing: 8) { | ||||
| 116 | + ForEach(cities, id: \.self) { city in | ||||
| 117 | + let on = selected == city | ||||
| 118 | + Button { | ||||
| 119 | + Haptics.tap() | ||||
| 120 | + selected = on ? nil : city | ||||
| 121 | + onChange() | ||||
| 122 | + } label: { | ||||
| 123 | + Text(city) | ||||
| 124 | + .font(.system(.caption, design: .rounded, weight: .bold)) | ||||
| 125 | + .padding(.horizontal, 12).padding(.vertical, 7) | ||||
| 126 | + .background(on ? accent : KATheme.surface(scheme), in: Capsule()) | ||||
| 127 | + .foregroundStyle(on ? .white : KATheme.ink(scheme)) | ||||
| 128 | + .overlay(Capsule().strokeBorder( | ||||
| 129 | + on ? accent : KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2)) | ||||
| 130 | + } | ||||
| 131 | + .accessibilityLabel("\(on ? "Retirer le filtre" : "Filtrer sur") \(city)") | ||||
| 132 | + } | ||||
| 133 | + } | ||||
| 134 | + .padding(.vertical, 2) | ||||
| 135 | + } | ||||
| 136 | + } | ||||
| 137 | +} | ||||
| 138 | + | ||||
| 139 | +// MARK: - VITRINE : le premier item en pleine largeur, prix sur la photo | ||||
| 140 | + | ||||
| 141 | +struct UniverseShowcaseCard: View { | ||||
| 142 | + let item: KAItem | ||||
| 143 | + let universe: Universe | ||||
| 144 | + @Environment(\.colorScheme) private var scheme | ||||
| 145 | + | ||||
| 146 | + var body: some View { | ||||
| 147 | + ZStack(alignment: .bottomLeading) { | ||||
| 148 | + KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol) | ||||
| 149 | + .frame(height: 224) | ||||
| 150 | + .frame(maxWidth: .infinity) | ||||
| 151 | + .clipped() | ||||
| 152 | + LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)], | ||||
| 153 | + startPoint: .top, endPoint: .bottom) | ||||
| 154 | + VStack(alignment: .leading, spacing: 5) { | ||||
| 155 | + Text("À LA UNE") | ||||
| 156 | + .font(KAFont.mono(8.5)) | ||||
| 157 | + .kerning(1.2) | ||||
| 158 | + .padding(.horizontal, 8).padding(.vertical, 4) | ||||
| 159 | + .background(universe.accent, in: Capsule()) | ||||
| 160 | + .foregroundStyle(.white) | ||||
| 161 | + Text(item.title) | ||||
| 162 | + .font(KAFont.display(19)) | ||||
| 163 | + .foregroundStyle(.white) | ||||
| 164 | + .lineLimit(2) | ||||
| 165 | + .multilineTextAlignment(.leading) | ||||
| 166 | + HStack(spacing: 8) { | ||||
| 167 | + if let p = item.priceLabel { | ||||
| 168 | + Text(p) | ||||
| 169 | + .font(.system(.subheadline, design: .rounded, weight: .heavy)) | ||||
| 170 | + .foregroundStyle(KATheme.lime) | ||||
| 171 | + } | ||||
| 172 | + if let c = item.city { | ||||
| 173 | + Text(c).font(.caption).foregroundStyle(.white.opacity(0.75)) | ||||
| 174 | + } | ||||
| 175 | + } | ||||
| 176 | + } | ||||
| 177 | + .padding(14) | ||||
| 178 | + } | ||||
| 179 | + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) | ||||
| 180 | + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) | ||||
| 181 | + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) | ||||
| 182 | + .background(RoundedRectangle(cornerRadius: 16, style: .continuous) | ||||
| 183 | + .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 1)) | ||||
| 184 | + .offset(x: 6, y: 6)) | ||||
| 185 | + .accessibilityElement(children: .combine) | ||||
| 186 | + .accessibilityLabel("À la une : \(item.title)\(item.priceLabel.map { ", \($0)" } ?? "")") | ||||
| 187 | + } | ||||
| 188 | +} | ||||
| 189 | + | ||||
| 190 | +// MARK: - Carte du carrousel « En vedette » | ||||
| 191 | + | ||||
| 192 | +struct UniverseFeaturedCard: View { | ||||
| 193 | + let item: KAItem | ||||
| 194 | + let universe: Universe | ||||
| 195 | + @Environment(\.colorScheme) private var scheme | ||||
| 196 | + | ||||
| 197 | + var body: some View { | ||||
| 198 | + VStack(alignment: .leading, spacing: 0) { | ||||
| 199 | + KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol) | ||||
| 200 | + .frame(width: 196, height: 112) | ||||
| 201 | + .clipped() | ||||
| 202 | + VStack(alignment: .leading, spacing: 4) { | ||||
| 203 | + Text(item.title) | ||||
| 204 | + .font(.subheadline.weight(.semibold)) | ||||
| 205 | + .lineLimit(2, reservesSpace: true) | ||||
| 206 | + .multilineTextAlignment(.leading) | ||||
| 207 | + HStack { | ||||
| 208 | + if let p = item.priceLabel { | ||||
| 209 | + Text(p) | ||||
| 210 | + .font(.system(.caption, design: .rounded).weight(.bold)) | ||||
| 211 | + .foregroundStyle(universe.accent) | ||||
| 212 | + .lineLimit(1) | ||||
| 213 | + } | ||||
| 214 | + Spacer() | ||||
| 215 | + if let c = item.city { | ||||
| 216 | + Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) | ||||
| 217 | + } | ||||
| 218 | + } | ||||
| 219 | + } | ||||
| 220 | + .padding(10) | ||||
| 221 | + } | ||||
| 222 | + .frame(width: 196, alignment: .topLeading) | ||||
| 223 | + .kaCard(accent: universe.accent) | ||||
| 224 | + } | ||||
| 225 | +} | ||||
| 226 | + | ||||
| 227 | +// MARK: - Cellule de la mosaïque magazine (grille 2 colonnes) | ||||
| 228 | + | ||||
| 229 | +struct UniverseMosaicCell: View { | ||||
| 230 | + let item: KAItem | ||||
| 231 | + let universe: Universe | ||||
| 232 | + @Environment(\.colorScheme) private var scheme | ||||
| 233 | + | ||||
| 234 | + var body: some View { | ||||
| 235 | + VStack(alignment: .leading, spacing: 0) { | ||||
| 236 | + if item.imageURL != nil { | ||||
| 237 | + KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol) | ||||
| 238 | + .frame(height: 108) | ||||
| 239 | + .frame(maxWidth: .infinity) | ||||
| 240 | + .clipped() | ||||
| 241 | + } else { | ||||
| 242 | + Rectangle() | ||||
| 243 | + .fill(universe.accent.opacity(0.12)) | ||||
| 244 | + .frame(height: 54) | ||||
| 245 | + .overlay(Image(systemName: universe.symbol) | ||||
| 246 | + .foregroundStyle(universe.accent.opacity(0.7))) | ||||
| 247 | + } | ||||
| 248 | + VStack(alignment: .leading, spacing: 3) { | ||||
| 249 | + Text(item.title) | ||||
| 250 | + .font(.caption.weight(.semibold)) | ||||
| 251 | + .lineLimit(2, reservesSpace: true) | ||||
| 252 | + .multilineTextAlignment(.leading) | ||||
| 253 | + if let p = item.priceLabel { | ||||
| 254 | + Text(p) | ||||
| 255 | + .font(.system(.caption2, design: .rounded).weight(.bold)) | ||||
| 256 | + .foregroundStyle(universe.accent) | ||||
| 257 | + .lineLimit(1) | ||||
| 258 | + } | ||||
| 259 | + if let c = item.city { | ||||
| 260 | + Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) | ||||
| 261 | + } | ||||
| 262 | + } | ||||
| 263 | + .padding(9) | ||||
| 264 | + } | ||||
| 265 | + .frame(maxWidth: .infinity, alignment: .topLeading) | ||||
| 266 | + .kaCard(accent: universe.accent) | ||||
| 267 | + } | ||||
| 268 | +} | ||||
modified
KA/Features/UniversesView.swift
+94 −12
@@ -50,9 +50,10 @@ struct UniversesView: View { | |||||
| 50 | 50 | ||||
| 51 | private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)] | 51 | private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)] | ||
| 52 | @State private var showMap = false | 52 | @State private var showMap = false | ||
| 53 | + @State private var path = NavigationPath() | ||||
| 53 | 54 | ||||
| 54 | var body: some View { | 55 | var body: some View { | ||
| 55 | − NavigationStack { | 56 | + NavigationStack(path: $path) { | ||
| 56 | ScrollView { | 57 | ScrollView { | ||
| 57 | LazyVGrid(columns: columns, spacing: 14) { | 58 | LazyVGrid(columns: columns, spacing: 14) { | ||
| 58 | ForEach(Ecosystem.all) { u in | 59 | ForEach(Ecosystem.all) { u in | ||
@@ -81,6 +82,11 @@ struct UniversesView: View { | |||||
| 81 | } | 82 | } | ||
| 82 | } | 83 | } | ||
| 83 | .task { | 84 | .task { | ||
| 85 | + // pilotage debug/captures : ouvre directement un univers | ||||
| 86 | + if let id = UserDefaults.standard.string(forKey: "ka.debug.universe"), | ||||
| 87 | + Ecosystem.universe(id) != nil, path.isEmpty { | ||||
| 88 | + path.append(id) | ||||
| 89 | + } | ||||
| 84 | await withTaskGroup(of: (String, Int?).self) { group in | 90 | await withTaskGroup(of: (String, Int?).self) { group in | ||
| 85 | for u in Ecosystem.all { | 91 | for u in Ecosystem.all { | ||
| 86 | group.addTask { (u.id, await UniverseService.liveTotal(u)) } | 92 | group.addTask { (u.id, await UniverseService.liveTotal(u)) } | ||
@@ -214,9 +220,8 @@ struct SiteHero: View { | |||||
| 214 | HStack(spacing: 7) { | 220 | HStack(spacing: 7) { | ||
| 215 | Circle().fill(universe.accent).frame(width: 8, height: 8) | 221 | Circle().fill(universe.accent).frame(width: 8, height: 8) | ||
| 216 | if let n = liveTotal { | 222 | if let n = liveTotal { | ||
| 217 | − Text(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) | 223 | + KACountUp(value: n, font: KAFont.display(15), | ||
| 218 | − .font(KAFont.display(15)) | 224 | + color: KATheme.ink(scheme)) | ||
| 219 | − .contentTransition(.numericText()) | ||||
| 220 | Text(universe.unit) | 225 | Text(universe.unit) | ||
| 221 | .font(.caption).foregroundStyle(KATheme.ink2(scheme)) | 226 | .font(.caption).foregroundStyle(KATheme.ink2(scheme)) | ||
| 222 | } else { | 227 | } else { | ||
@@ -241,6 +246,8 @@ struct UniverseHomeView: View { | |||||
| 241 | @State private var filterParams: [String: String] = [:] | 246 | @State private var filterParams: [String: String] = [:] | ||
| 242 | @State private var liveTotal: Int? | 247 | @State private var liveTotal: Int? | ||
| 243 | @State private var showSite = false | 248 | @State private var showSite = false | ||
| 249 | + @State private var cityFilter: String? | ||||
| 250 | + @State private var cityPool: [String] = [] // top villes du premier chargement | ||||
| 244 | @Environment(\.colorScheme) private var scheme | 251 | @Environment(\.colorScheme) private var scheme | ||
| 245 | 252 | ||||
| 246 | var body: some View { | 253 | var body: some View { | ||
@@ -276,11 +283,13 @@ struct UniverseHomeView: View { | |||||
| 276 | EmbeddedSite(universe: universe) | 283 | EmbeddedSite(universe: universe) | ||
| 277 | } | 284 | } | ||
| 278 | 285 | ||||
| 279 | − /// En-tête FIDÈLE au site de l'univers : ticker encre, kicker vert mono, | 286 | + /// En-tête FIDÈLE au site : bande teintée accent + logo filigrane autour | ||
| 280 | − /// titre Space Grotesk avec segment surligné accent, sous-titre, pilule live. | 287 | + /// du héros (ticker encre, kicker, titre surligné, pilule live roulante). | ||
| 281 | private var hero: some View { | 288 | private var hero: some View { | ||
| 282 | VStack(alignment: .leading, spacing: 0) { | 289 | VStack(alignment: .leading, spacing: 0) { | ||
| 283 | − SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true } | 290 | + UniverseHeroBand(universe: universe) { | ||
| 291 | + SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true } | ||||
| 292 | + } | ||||
| 284 | FilterBar(universe: universe, params: $filterParams) { | 293 | FilterBar(universe: universe, params: $filterParams) { | ||
| 285 | Task { await load() } | 294 | Task { await load() } | ||
| 286 | } | 295 | } | ||
@@ -290,7 +299,7 @@ struct UniverseHomeView: View { | |||||
| 290 | 299 | ||||
| 291 | private var list: some View { | 300 | private var list: some View { | ||
| 292 | ScrollView { | 301 | ScrollView { | ||
| 293 | − LazyVStack(spacing: 12) { | 302 | + LazyVStack(spacing: 14) { | ||
| 294 | hero | 303 | hero | ||
| 295 | if loading { | 304 | if loading { | ||
| 296 | ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } | 305 | ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } | ||
@@ -307,10 +316,16 @@ struct UniverseHomeView: View { | |||||
| 307 | message: "Essayez d'autres mots-clés.") | 316 | message: "Essayez d'autres mots-clés.") | ||
| 308 | } | 317 | } | ||
| 309 | } else { | 318 | } else { | ||
| 310 | − ForEach(items) { item in | 319 | + UniverseStatStrip(universe: universe, liveTotal: liveTotal, | ||
| 311 | − NavigationLink(value: item) { KAItemRow(item: item) } | 320 | + shown: items.count, | ||
| 312 | − .buttonStyle(KAPressStyle()) | 321 | + cityCount: Set(items.compactMap(\.city)).count) | ||
| 322 | + if cityPool.count > 1 { | ||||
| 323 | + UniverseCityChips(cities: cityPool, selected: $cityFilter, | ||||
| 324 | + accent: universe.accent) { | ||||
| 325 | + Task { await load() } | ||||
| 326 | + } | ||||
| 313 | } | 327 | } | ||
| 328 | + magazine | ||||
| 314 | } | 329 | } | ||
| 315 | } | 330 | } | ||
| 316 | .padding(16) | 331 | .padding(16) | ||
@@ -326,11 +341,78 @@ struct UniverseHomeView: View { | |||||
| 326 | } | 341 | } | ||
| 327 | } | 342 | } | ||
| 328 | 343 | ||||
| 344 | + /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine | ||||
| 345 | + /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos | ||||
| 346 | + /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. | ||||
| 347 | + @ViewBuilder | ||||
| 348 | + private var magazine: some View { | ||||
| 349 | + let visuals = items.filter { $0.imageURL != nil } | ||||
| 350 | + if visuals.count >= 3, let showcase = visuals.first { | ||||
| 351 | + let featured = Array(visuals.dropFirst().prefix(6)) | ||||
| 352 | + let usedIDs = Set([showcase.id] + featured.map(\.id)) | ||||
| 353 | + let rest = items.filter { !usedIDs.contains($0.id) } | ||||
| 354 | + | ||||
| 355 | + NavigationLink(value: showcase) { | ||||
| 356 | + UniverseShowcaseCard(item: showcase, universe: universe) | ||||
| 357 | + } | ||||
| 358 | + .buttonStyle(KAPressStyle()) | ||||
| 359 | + .kaScrollPop(axis: .vertical) | ||||
| 360 | + | ||||
| 361 | + if !featured.isEmpty { | ||||
| 362 | + UniverseSectionHeader(title: "En vedette", accent: universe.accent) | ||||
| 363 | + ScrollView(.horizontal, showsIndicators: false) { | ||||
| 364 | + HStack(spacing: 12) { | ||||
| 365 | + ForEach(featured) { it in | ||||
| 366 | + NavigationLink(value: it) { | ||||
| 367 | + UniverseFeaturedCard(item: it, universe: universe) | ||||
| 368 | + } | ||||
| 369 | + .buttonStyle(KAPressStyle()) | ||||
| 370 | + .kaScrollPop() | ||||
| 371 | + } | ||||
| 372 | + } | ||||
| 373 | + .scrollTargetLayout() | ||||
| 374 | + .padding(.vertical, 4) | ||||
| 375 | + } | ||||
| 376 | + .scrollTargetBehavior(.viewAligned) | ||||
| 377 | + } | ||||
| 378 | + | ||||
| 379 | + if !rest.isEmpty { | ||||
| 380 | + UniverseSectionHeader(title: "Tout le flux", accent: universe.accent, | ||||
| 381 | + detail: "\(rest.count)") | ||||
| 382 | + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), | ||||
| 383 | + GridItem(.flexible(), spacing: 12)], spacing: 12) { | ||||
| 384 | + ForEach(rest) { it in | ||||
| 385 | + NavigationLink(value: it) { | ||||
| 386 | + UniverseMosaicCell(item: it, universe: universe) | ||||
| 387 | + } | ||||
| 388 | + .buttonStyle(KAPressStyle()) | ||||
| 389 | + .kaScrollPop(axis: .vertical) | ||||
| 390 | + } | ||||
| 391 | + } | ||||
| 392 | + } | ||||
| 393 | + } else { | ||||
| 394 | + ForEach(items) { item in | ||||
| 395 | + NavigationLink(value: item) { KAItemRow(item: item) } | ||||
| 396 | + .buttonStyle(KAPressStyle()) | ||||
| 397 | + .kaScrollPop(axis: .vertical) | ||||
| 398 | + } | ||||
| 399 | + } | ||||
| 400 | + } | ||||
| 401 | + | ||||
| 329 | private func load() async { | 402 | private func load() async { | ||
| 330 | loading = items.isEmpty | 403 | loading = items.isEmpty | ||
| 331 | errorText = nil | 404 | errorText = nil | ||
| 332 | do { | 405 | do { | ||
| 333 | − items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40, params: filterParams) | 406 | + items = try await UniverseService.fetch(universe, | ||
| 407 | + query: query.isEmpty ? nil : query, | ||||
| 408 | + city: cityFilter, | ||||
| 409 | + limit: 40, params: filterParams) | ||||
| 410 | + // top villes mémorisées sur un chargement NON filtré (chips stables) | ||||
| 411 | + if query.isEmpty, cityFilter == nil, filterParams.isEmpty { | ||||
| 412 | + let counts = Dictionary(grouping: items.compactMap(\.city), by: { $0 }) | ||||
| 413 | + .mapValues(\.count) | ||||
| 414 | + cityPool = counts.sorted { $0.value > $1.value }.prefix(8).map(\.key) | ||||
| 415 | + } | ||||
| 334 | } catch { | 416 | } catch { | ||
| 335 | errorText = (error as? URLError)?.code == .notConnectedToInternet | 417 | errorText = (error as? URLError)?.code == .notConnectedToInternet | ||
| 336 | ? "Vous êtes hors ligne." : "Le service ne répond pas." | 418 | ? "Vous êtes hors ligne." : "Le service ne répond pas." | ||
| 337 | 419 | ||||