SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

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 1 mo ago (Aug 25, 2026) parent f5166e9

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 51 private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
52 52 @State private var showMap = false
53 + @State private var path = NavigationPath()
53 54
54 55 var body: some View {
55 − NavigationStack {
56 + NavigationStack(path: $path) {
56 57 ScrollView {
57 58 LazyVGrid(columns: columns, spacing: 14) {
58 59 ForEach(Ecosystem.all) { u in
@@ -81,6 +82,11 @@ struct UniversesView: View {
81 82 }
82 83 }
83 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 90 await withTaskGroup(of: (String, Int?).self) { group in
85 91 for u in Ecosystem.all {
86 92 group.addTask { (u.id, await UniverseService.liveTotal(u)) }
@@ -214,9 +220,8 @@ struct SiteHero: View {
214 220 HStack(spacing: 7) {
215 221 Circle().fill(universe.accent).frame(width: 8, height: 8)
216 222 if let n = liveTotal {
217 − Text(n.formatted(.number.locale(Locale(identifier: "fr_CA"))))
218 − .font(KAFont.display(15))
219 − .contentTransition(.numericText())
223 + KACountUp(value: n, font: KAFont.display(15),
224 + color: KATheme.ink(scheme))
220 225 Text(universe.unit)
221 226 .font(.caption).foregroundStyle(KATheme.ink2(scheme))
222 227 } else {
@@ -241,6 +246,8 @@ struct UniverseHomeView: View {
241 246 @State private var filterParams: [String: String] = [:]
242 247 @State private var liveTotal: Int?
243 248 @State private var showSite = false
249 + @State private var cityFilter: String?
250 + @State private var cityPool: [String] = [] // top villes du premier chargement
244 251 @Environment(\.colorScheme) private var scheme
245 252
246 253 var body: some View {
@@ -276,11 +283,13 @@ struct UniverseHomeView: View {
276 283 EmbeddedSite(universe: universe)
277 284 }
278 285
279 − /// En-tête FIDÈLE au site de l'univers : ticker encre, kicker vert mono,
280 − /// titre Space Grotesk avec segment surligné accent, sous-titre, pilule live.
286 + /// En-tête FIDÈLE au site : bande teintée accent + logo filigrane autour
287 + /// du héros (ticker encre, kicker, titre surligné, pilule live roulante).
281 288 private var hero: some View {
282 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 293 FilterBar(universe: universe, params: $filterParams) {
285 294 Task { await load() }
286 295 }
@@ -290,7 +299,7 @@ struct UniverseHomeView: View {
290 299
291 300 private var list: some View {
292 301 ScrollView {
293 − LazyVStack(spacing: 12) {
302 + LazyVStack(spacing: 14) {
294 303 hero
295 304 if loading {
296 305 ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }
@@ -307,10 +316,16 @@ struct UniverseHomeView: View {
307 316 message: "Essayez d'autres mots-clés.")
308 317 }
309 318 } else {
310 − ForEach(items) { item in
311 − NavigationLink(value: item) { KAItemRow(item: item) }
312 − .buttonStyle(KAPressStyle())
319 + UniverseStatStrip(universe: universe, liveTotal: liveTotal,
320 + shown: items.count,
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 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 402 private func load() async {
330 403 loading = items.isEmpty
331 404 errorText = nil
332 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 416 } catch {
335 417 errorText = (error as? URLError)?.code == .notConnectedToInternet
336 418 ? "Vous êtes hors ligne." : "Le service ne répond pas."
337 419