Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// 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'univers4// (compteurs roulants), villes rapides filtrantes, VITRINE immersive du5// premier item, carrousel « En vedette » et mosaïque magazine 2 colonnes.6// S'adapte : univers sans photos (Job·Ka, Trouve·Ka) → liste éditoriale.7import SwiftUI89// MARK: - Bande héros : gradient accent + logo en filigrane autour du SiteHero1011struct UniverseHeroBand<Content: View>: View {12 let universe: Universe13 @ViewBuilder var content: Content14 @Environment(\.colorScheme) private var scheme1516 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 content30 .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}3738// MARK: - En-tête de section éditorial (carré accent + mono uppercase)3940struct UniverseSectionHeader: View {41 let title: String42 let accent: Color43 var detail: String? = nil44 @Environment(\.colorScheme) private var scheme4546 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}6566// MARK: - Le pouls de l'univers (tuiles mono, compteurs roulants)6768struct UniverseStatStrip: View {69 let universe: Universe70 let liveTotal: Int?71 let shown: Int72 let cityCount: Int73 @Environment(\.colorScheme) private var scheme7475 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 }9091 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}103104// MARK: - Villes rapides (filtre en un tap)105106struct UniverseCityChips: View {107 let cities: [String]108 @Binding var selected: String?109 let accent: Color110 var onChange: () -> Void111 @Environment(\.colorScheme) private var scheme112113 var body: some View {114 ScrollView(.horizontal, showsIndicators: false) {115 HStack(spacing: 8) {116 ForEach(cities, id: \.self) { city in117 let on = selected == city118 Button {119 Haptics.tap()120 selected = on ? nil : city121 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}138139// MARK: - VITRINE : le premier item en pleine largeur, prix sur la photo140141struct UniverseShowcaseCard: View {142 let item: KAItem143 let universe: Universe144 @Environment(\.colorScheme) private var scheme145146 var body: some View {147 ZStack(alignment: .bottomLeading) {148 // image en overlay d'un cadre fixe : jamais de débordement de layout149 Color.clear150 .frame(height: 224)151 .frame(maxWidth: .infinity)152 .overlay(KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol))153 .clipped()154 LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)],155 startPoint: .top, endPoint: .bottom)156 VStack(alignment: .leading, spacing: 5) {157 Text("À LA UNE")158 .font(KAFont.mono(8.5))159 .kerning(1.2)160 .padding(.horizontal, 8).padding(.vertical, 4)161 .background(universe.accent, in: Capsule())162 .foregroundStyle(.white)163 Text(item.title)164 .font(KAFont.display(19))165 .foregroundStyle(.white)166 .lineLimit(2)167 .multilineTextAlignment(.leading)168 HStack(spacing: 8) {169 if let p = item.priceLabel {170 Text(p)171 .font(.system(.subheadline, design: .rounded, weight: .heavy))172 .foregroundStyle(KATheme.lime)173 }174 if let c = item.city {175 Text(c).font(.caption).foregroundStyle(.white.opacity(0.75))176 }177 }178 }179 .padding(14)180 }181 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))182 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)183 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))184 .background(RoundedRectangle(cornerRadius: 16, style: .continuous)185 .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 1))186 .offset(x: 6, y: 6))187 .accessibilityElement(children: .combine)188 .accessibilityLabel("À la une : \(item.title)\(item.priceLabel.map { ", \($0)" } ?? "")")189 }190}191192// MARK: - Carte du carrousel « En vedette »193194struct UniverseFeaturedCard: View {195 let item: KAItem196 let universe: Universe197 @Environment(\.colorScheme) private var scheme198199 var body: some View {200 VStack(alignment: .leading, spacing: 0) {201 KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)202 .frame(width: 196, height: 112)203 .clipped()204 VStack(alignment: .leading, spacing: 4) {205 Text(item.title)206 .font(.subheadline.weight(.semibold))207 .lineLimit(2, reservesSpace: true)208 .multilineTextAlignment(.leading)209 HStack {210 if let p = item.priceLabel {211 Text(p)212 .font(.system(.caption, design: .rounded).weight(.bold))213 .foregroundStyle(universe.accent)214 .lineLimit(1)215 }216 Spacer()217 if let c = item.city {218 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)219 }220 }221 }222 .padding(10)223 }224 .frame(width: 196, alignment: .topLeading)225 .kaCard(accent: universe.accent)226 }227}228229// MARK: - Cellule de la mosaïque magazine (grille 2 colonnes)230231struct UniverseMosaicCell: View {232 let item: KAItem233 let universe: Universe234 @Environment(\.colorScheme) private var scheme235236 var body: some View {237 VStack(alignment: .leading, spacing: 0) {238 if item.imageURL != nil {239 KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)240 .frame(height: 108)241 .frame(maxWidth: .infinity)242 .clipped()243 } else {244 Rectangle()245 .fill(universe.accent.opacity(0.12))246 .frame(height: 54)247 .overlay(Image(systemName: universe.symbol)248 .foregroundStyle(universe.accent.opacity(0.7)))249 }250 VStack(alignment: .leading, spacing: 3) {251 Text(item.title)252 .font(.caption.weight(.semibold))253 .lineLimit(2, reservesSpace: true)254 .multilineTextAlignment(.leading)255 if let p = item.priceLabel {256 Text(p)257 .font(.system(.caption2, design: .rounded).weight(.bold))258 .foregroundStyle(universe.accent)259 .lineLimit(1)260 }261 if let c = item.city {262 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)263 }264 }265 .padding(9)266 }267 .frame(maxWidth: .infinity, alignment: .topLeading)268 .kaCard(accent: universe.accent)269 }270}271