SPB Git

spb/ka-ios

Public

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>
Simon-Pierre Boucher committed (Aug 25, 2026) parent f5166e9 Browse files

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
Menu

Theme