Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Theme.swift — design system Groupe-KA adapté iOS (HIG) : papier/encre en3// clair, encre/papier en sombre, accents par univers, composants signature4// (carte à ombre décalée, puce, wordmark) + haptique.5import SwiftUI6import UIKit78// MARK: - Palette910enum KATheme {11 static let paperLight = Color(hex: "#f5f3ee")12 static let paperDark = Color(hex: "#101410")13 static let inkLight = Color(hex: "#141814")14 static let inkDark = Color(hex: "#f0efe8")15 static let lime = Color(hex: "#d9f26b")16 static let green = Color(hex: "#1c5c41")1718 static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? paperDark : paperLight }19 static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? inkDark : inkLight }20 static func surface(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#1a1f1a") : .white }21 static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") }22}2324// MARK: - Haptique2526enum Haptics {27 static func tap() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }28 static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) }29 static func rigid() { UIImpactFeedbackGenerator(style: .rigid).impactOccurred() }30}3132// MARK: - Carte « éditorial sharp » — tokens EXACTS des sites (tokens.css) :33// rayon 10 px, bordure encre 1,5 px, ombre décalée DURE 6×6 pleine encre.3435struct KACard: ViewModifier {36 @Environment(\.colorScheme) private var scheme37 var accent: Color? = nil3839 func body(content: Content) -> some View {40 content41 .background(KATheme.surface(scheme))42 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))43 .overlay(44 RoundedRectangle(cornerRadius: 10, style: .continuous)45 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)46 )47 .background(48 RoundedRectangle(cornerRadius: 10, style: .continuous)49 .fill(scheme == .dark50 ? (accent ?? KATheme.inkDark).opacity(0.25)51 : KATheme.inkLight)52 .offset(x: 6, y: 6)53 )54 }55}5657extension View {58 func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }59}6061// MARK: - Wordmark (« Lou » + boîte [Ka] accent)6263struct KAWordmark: View {64 let universe: Universe65 var size: CGFloat = 2266 @Environment(\.colorScheme) private var scheme6768 var body: some View {69 // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc70 let parts = universe.wordmark.split(separator: "·", maxSplits: 1)71 HStack(alignment: .firstTextBaseline, spacing: 3) {72 Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark)73 .font(KAFont.display(size))74 if parts.count > 1 {75 Text(String(parts[1]))76 .font(KAFont.display(size * 0.86))77 .foregroundStyle(.white)78 .padding(.horizontal, size * 0.28)79 .padding(.vertical, size * 0.08)80 .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))81 }82 }83 .foregroundStyle(KATheme.ink(scheme))84 .accessibilityLabel(universe.name)85 }86}8788// MARK: - Logo officiel (tuile de marque ka-ui, PNG 512 aux coins déjà arrondis)8990struct KALogo: View {91 let assetID: String // « lou-ka » … « groupe-ka »92 var size: CGFloat = 409394 init(universe: Universe, size: CGFloat = 40) {95 self.assetID = universe.id96 self.size = size97 }98 init(assetID: String, size: CGFloat = 40) {99 self.assetID = assetID100 self.size = size101 }102103 var body: some View {104 Image("logo-\(assetID)")105 .resizable()106 .aspectRatio(contentMode: .fit)107 .frame(width: size, height: size)108 .accessibilityHidden(true)109 }110}111112struct GroupeKAMark: View {113 var size: CGFloat = 24114 @Environment(\.colorScheme) private var scheme115 var body: some View {116 HStack(alignment: .firstTextBaseline, spacing: 4) {117 Text("Groupe").font(KAFont.display(size))118 Text("KA")119 .font(KAFont.display(size * 0.9))120 .foregroundStyle(KATheme.lime)121 .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)122 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))123 .rotationEffect(.degrees(-2))124 }125 .fixedSize()126 .foregroundStyle(KATheme.ink(scheme))127 .accessibilityLabel("Groupe KA")128 }129}130131// MARK: - Puce mono (klabel)132133struct KAChip: View {134 let text: String135 var accent: Color? = nil136 @Environment(\.colorScheme) private var scheme137 var body: some View {138 Text(text)139 .font(KAFont.mono(10))140 .textCase(.uppercase)141 .padding(.horizontal, 9).padding(.vertical, 4)142 .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())143 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))144 }145}146147// MARK: - Rangée d'item universelle148149struct KAItemRow: View {150 let item: KAItem151 @Environment(\.colorScheme) private var scheme152 @EnvironmentObject private var favorites: FavoritesStore153154 private var universe: Universe? { Ecosystem.universe(item.universeID) }155156 var body: some View {157 HStack(alignment: .top, spacing: 12) {158 if let img = item.imageURL {159 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")160 .frame(width: 74, height: 74)161 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))162 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)163 .strokeBorder(.primary.opacity(0.25), lineWidth: 1))164 .accessibilityHidden(true)165 } else {166 RoundedRectangle(cornerRadius: 8, style: .continuous)167 .fill(universe?.accent.opacity(0.9) ?? .gray)168 .frame(width: 5)169 .padding(.vertical, 2)170 }171 VStack(alignment: .leading, spacing: 4) {172 Text(item.title)173 .font(.headline)174 .lineLimit(2)175 if let sub = item.subtitle, !sub.isEmpty {176 Text(sub).font(.subheadline)177 .foregroundStyle(KATheme.ink2(scheme)).lineLimit(2)178 }179 HStack(spacing: 8) {180 if let price = item.priceLabel {181 Text(price).font(.system(.subheadline, design: .rounded).weight(.bold))182 .foregroundStyle(universe?.accent ?? .primary)183 }184 if let city = item.city, !city.isEmpty {185 Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme))186 }187 Spacer(minLength: 0)188 if let u = universe {189 KAChip(text: u.wordmark, accent: u.accent)190 }191 }192 }193 if favorites.isFavorite(item) {194 Image(systemName: "heart.fill")195 .foregroundStyle(.red).font(.caption)196 .accessibilityLabel("Dans vos favoris")197 }198 }199 .padding(12)200 .kaCard(accent: universe?.accent)201 }202}203204// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé)205206struct KAImage: View {207 let url: URL?208 var accent: Color = .gray209 var symbol: String = "photo"210211 var body: some View {212 AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in213 switch phase {214 case .success(let image):215 image.resizable().aspectRatio(contentMode: .fill)216 .transition(.opacity)217 case .failure:218 accent.opacity(0.1)219 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6)))220 default:221 accent.opacity(0.08)222 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35)))223 }224 }225 }226}227228// MARK: - Pression tactile (échelle + réactivité)229230struct KAPressStyle: ButtonStyle {231 func makeBody(configuration: Configuration) -> some View {232 configuration.label233 .scaleEffect(configuration.isPressed ? 0.97 : 1)234 .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)235 }236}237238// MARK: - Squelette de chargement (liste)239240struct KASkeletonRow: View {241 @Environment(\.colorScheme) private var scheme242 var body: some View {243 HStack(alignment: .top, spacing: 12) {244 RoundedRectangle(cornerRadius: 10).fill(.quaternary).frame(width: 74, height: 74)245 VStack(alignment: .leading, spacing: 8) {246 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14)247 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11)248 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11)249 }250 }251 .padding(12).kaCard()252 .redacted(reason: .placeholder)253 .shimmering()254 }255}256257struct Shimmer: ViewModifier {258 @State private var on = false259 func body(content: Content) -> some View {260 content261 .opacity(on ? 0.5 : 0.9)262 .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on)263 .onAppear { on = true }264 }265}266extension View { func shimmering() -> some View { modifier(Shimmer()) } }267268// MARK: - États269270struct KAEmptyState: View {271 let symbol: String272 let title: String273 var message: String? = nil274 var body: some View {275 VStack(spacing: 10) {276 Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)277 Text(title).font(.headline)278 if let m = message {279 Text(m).font(.subheadline).foregroundStyle(.secondary)280 .multilineTextAlignment(.center)281 }282 }283 .padding(30)284 .frame(maxWidth: .infinity)285 }286}287