// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Theme.swift — design system Groupe-KA adapté iOS (HIG) : papier/encre en // clair, encre/papier en sombre, accents par univers, composants signature // (carte à ombre décalée, puce, wordmark) + haptique. import SwiftUI import UIKit // MARK: - Palette enum KATheme { static let paperLight = Color(hex: "#f5f3ee") static let paperDark = Color(hex: "#101410") static let inkLight = Color(hex: "#141814") static let inkDark = Color(hex: "#f0efe8") static let lime = Color(hex: "#d9f26b") static let green = Color(hex: "#1c5c41") static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? paperDark : paperLight } static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? inkDark : inkLight } static func surface(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#1a1f1a") : .white } static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") } } // MARK: - Haptique enum Haptics { static func tap() { UIImpactFeedbackGenerator(style: .light).impactOccurred() } static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) } static func rigid() { UIImpactFeedbackGenerator(style: .rigid).impactOccurred() } } // MARK: - Carte « éditorial sharp » — tokens EXACTS des sites (tokens.css) : // rayon 10 px, bordure encre 1,5 px, ombre décalée DURE 6×6 pleine encre. struct KACard: ViewModifier { @Environment(\.colorScheme) private var scheme var accent: Color? = nil func body(content: Content) -> some View { content .background(KATheme.surface(scheme)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5) ) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(scheme == .dark ? (accent ?? KATheme.inkDark).opacity(0.25) : KATheme.inkLight) .offset(x: 6, y: 6) ) } } extension View { func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) } } // MARK: - Wordmark (« Lou » + boîte [Ka] accent) struct KAWordmark: View { let universe: Universe var size: CGFloat = 22 @Environment(\.colorScheme) private var scheme var body: some View { // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc let parts = universe.wordmark.split(separator: "·", maxSplits: 1) HStack(alignment: .firstTextBaseline, spacing: 3) { Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark) .font(KAFont.display(size)) if parts.count > 1 { Text(String(parts[1])) .font(KAFont.display(size * 0.86)) .foregroundStyle(.white) .padding(.horizontal, size * 0.28) .padding(.vertical, size * 0.08) .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous)) } } .foregroundStyle(KATheme.ink(scheme)) .accessibilityLabel(universe.name) } } // MARK: - Logo officiel (tuile de marque ka-ui, PNG 512 aux coins déjà arrondis) struct KALogo: View { let assetID: String // « lou-ka » … « groupe-ka » var size: CGFloat = 40 init(universe: Universe, size: CGFloat = 40) { self.assetID = universe.id self.size = size } init(assetID: String, size: CGFloat = 40) { self.assetID = assetID self.size = size } var body: some View { Image("logo-\(assetID)") .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .accessibilityHidden(true) } } struct GroupeKAMark: View { var size: CGFloat = 24 @Environment(\.colorScheme) private var scheme var body: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("Groupe").font(KAFont.display(size)) Text("KA") .font(KAFont.display(size * 0.9)) .foregroundStyle(KATheme.lime) .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) .rotationEffect(.degrees(-2)) } .fixedSize() .foregroundStyle(KATheme.ink(scheme)) .accessibilityLabel("Groupe KA") } } // MARK: - Puce mono (klabel) struct KAChip: View { let text: String var accent: Color? = nil @Environment(\.colorScheme) private var scheme var body: some View { Text(text) .font(KAFont.mono(10)) .textCase(.uppercase) .padding(.horizontal, 9).padding(.vertical, 4) .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1)) } } // MARK: - Rangée d'item universelle struct KAItemRow: View { let item: KAItem @Environment(\.colorScheme) private var scheme @EnvironmentObject private var favorites: FavoritesStore private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { HStack(alignment: .top, spacing: 12) { if let img = item.imageURL { KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .frame(width: 74, height: 74) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(.primary.opacity(0.25), lineWidth: 1)) .accessibilityHidden(true) } else { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(universe?.accent.opacity(0.9) ?? .gray) .frame(width: 5) .padding(.vertical, 2) } VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.headline) .lineLimit(2) if let sub = item.subtitle, !sub.isEmpty { Text(sub).font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)).lineLimit(2) } HStack(spacing: 8) { if let price = item.priceLabel { Text(price).font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } if let city = item.city, !city.isEmpty { Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)) } Spacer(minLength: 0) if let u = universe { KAChip(text: u.wordmark, accent: u.accent) } } } if favorites.isFavorite(item) { Image(systemName: "heart.fill") .foregroundStyle(.red).font(.caption) .accessibilityLabel("Dans vos favoris") } } .padding(12) .kaCard(accent: universe?.accent) } } // MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé) struct KAImage: View { let url: URL? var accent: Color = .gray var symbol: String = "photo" var body: some View { AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) .transition(.opacity) case .failure: accent.opacity(0.1) .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6))) default: accent.opacity(0.08) .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35))) } } } } // MARK: - Pression tactile (échelle + réactivité) struct KAPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) } } // MARK: - Squelette de chargement (liste) struct KASkeletonRow: View { @Environment(\.colorScheme) private var scheme var body: some View { HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 10).fill(.quaternary).frame(width: 74, height: 74) VStack(alignment: .leading, spacing: 8) { RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14) RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11) RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11) } } .padding(12).kaCard() .redacted(reason: .placeholder) .shimmering() } } struct Shimmer: ViewModifier { @State private var on = false func body(content: Content) -> some View { content .opacity(on ? 0.5 : 0.9) .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on) .onAppear { on = true } } } extension View { func shimmering() -> some View { modifier(Shimmer()) } } // MARK: - États struct KAEmptyState: View { let symbol: String let title: String var message: String? = nil var body: some View { VStack(spacing: 10) { Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary) Text(title).font(.headline) if let m = message { Text(m).font(.subheadline).foregroundStyle(.secondary) .multilineTextAlignment(.center) } } .padding(30) .frame(maxWidth: .infinity) } }