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 » (bordure encre + ombre décalée)3334struct KACard: ViewModifier {35 @Environment(\.colorScheme) private var scheme36 var accent: Color? = nil3738 func body(content: Content) -> some View {39 content40 .background(KATheme.surface(scheme))41 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))42 .overlay(43 RoundedRectangle(cornerRadius: 14, style: .continuous)44 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.4)45 )46 .background(47 RoundedRectangle(cornerRadius: 14, style: .continuous)48 .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16))49 .offset(x: 5, y: 5)50 )51 }52}5354extension View {55 func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }56}5758// MARK: - Wordmark (« Lou » + boîte [Ka] accent)5960struct KAWordmark: View {61 let universe: Universe62 var size: CGFloat = 2263 @Environment(\.colorScheme) private var scheme6465 var body: some View {66 let parts = universe.wordmark.split(separator: "·", maxSplits: 1)67 HStack(alignment: .firstTextBaseline, spacing: 3) {68 Text(parts.first.map(String.init) ?? universe.wordmark)69 .font(.system(size: size, weight: .bold, design: .rounded))70 if parts.count > 1 {71 Text(String(parts[1]))72 .font(.system(size: size * 0.86, weight: .bold, design: .rounded))73 .foregroundStyle(universe.accent)74 .padding(.horizontal, size * 0.28)75 .padding(.vertical, size * 0.08)76 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))77 .rotationEffect(.degrees(-2))78 }79 }80 .foregroundStyle(KATheme.ink(scheme))81 .accessibilityLabel(universe.name)82 }83}8485struct GroupeKAMark: View {86 var size: CGFloat = 2487 @Environment(\.colorScheme) private var scheme88 var body: some View {89 HStack(alignment: .firstTextBaseline, spacing: 4) {90 Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded))91 Text("KA")92 .font(.system(size: size * 0.9, weight: .bold, design: .rounded))93 .foregroundStyle(KATheme.lime)94 .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)95 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))96 .rotationEffect(.degrees(-2))97 }98 .fixedSize()99 .foregroundStyle(KATheme.ink(scheme))100 .accessibilityLabel("Groupe KA")101 }102}103104// MARK: - Puce mono (klabel)105106struct KAChip: View {107 let text: String108 var accent: Color? = nil109 @Environment(\.colorScheme) private var scheme110 var body: some View {111 Text(text)112 .font(.system(.caption2, design: .monospaced).weight(.bold))113 .textCase(.uppercase)114 .padding(.horizontal, 9).padding(.vertical, 4)115 .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())116 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))117 }118}119120// MARK: - Rangée d'item universelle121122struct KAItemRow: View {123 let item: KAItem124 @Environment(\.colorScheme) private var scheme125 @EnvironmentObject private var favorites: FavoritesStore126127 private var universe: Universe? { Ecosystem.universe(item.universeID) }128129 var body: some View {130 HStack(alignment: .top, spacing: 12) {131 if let img = item.imageURL {132 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")133 .frame(width: 74, height: 74)134 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))135 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)136 .strokeBorder(.primary.opacity(0.25), lineWidth: 1))137 .accessibilityHidden(true)138 } else {139 RoundedRectangle(cornerRadius: 8, style: .continuous)140 .fill(universe?.accent.opacity(0.9) ?? .gray)141 .frame(width: 5)142 .padding(.vertical, 2)143 }144 VStack(alignment: .leading, spacing: 4) {145 Text(item.title)146 .font(.headline)147 .lineLimit(2)148 if let sub = item.subtitle, !sub.isEmpty {149 Text(sub).font(.subheadline)150 .foregroundStyle(KATheme.ink2(scheme)).lineLimit(2)151 }152 HStack(spacing: 8) {153 if let price = item.priceLabel {154 Text(price).font(.system(.subheadline, design: .rounded).weight(.bold))155 .foregroundStyle(universe?.accent ?? .primary)156 }157 if let city = item.city, !city.isEmpty {158 Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme))159 }160 Spacer(minLength: 0)161 if let u = universe {162 KAChip(text: u.wordmark, accent: u.accent)163 }164 }165 }166 if favorites.isFavorite(item) {167 Image(systemName: "heart.fill")168 .foregroundStyle(.red).font(.caption)169 .accessibilityLabel("Dans vos favoris")170 }171 }172 .padding(12)173 .kaCard(accent: universe?.accent)174 }175}176177// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé)178179struct KAImage: View {180 let url: URL?181 var accent: Color = .gray182 var symbol: String = "photo"183184 var body: some View {185 AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in186 switch phase {187 case .success(let image):188 image.resizable().aspectRatio(contentMode: .fill)189 .transition(.opacity)190 case .failure:191 accent.opacity(0.1)192 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6)))193 default:194 accent.opacity(0.08)195 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35)))196 }197 }198 }199}200201// MARK: - Pression tactile (échelle + réactivité)202203struct KAPressStyle: ButtonStyle {204 func makeBody(configuration: Configuration) -> some View {205 configuration.label206 .scaleEffect(configuration.isPressed ? 0.97 : 1)207 .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)208 }209}210211// MARK: - Squelette de chargement (liste)212213struct KASkeletonRow: View {214 @Environment(\.colorScheme) private var scheme215 var body: some View {216 HStack(alignment: .top, spacing: 12) {217 RoundedRectangle(cornerRadius: 10).fill(.quaternary).frame(width: 74, height: 74)218 VStack(alignment: .leading, spacing: 8) {219 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14)220 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11)221 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11)222 }223 }224 .padding(12).kaCard()225 .redacted(reason: .placeholder)226 .shimmering()227 }228}229230struct Shimmer: ViewModifier {231 @State private var on = false232 func body(content: Content) -> some View {233 content234 .opacity(on ? 0.5 : 0.9)235 .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on)236 .onAppear { on = true }237 }238}239extension View { func shimmering() -> some View { modifier(Shimmer()) } }240241// MARK: - États242243struct KAEmptyState: View {244 let symbol: String245 let title: String246 var message: String? = nil247 var body: some View {248 VStack(spacing: 10) {249 Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)250 Text(title).font(.headline)251 if let m = message {252 Text(m).font(.subheadline).foregroundStyle(.secondary)253 .multilineTextAlignment(.center)254 }255 }256 .padding(30)257 .frame(maxWidth: .infinity)258 }259}260