SPB Git

spb/ka-ios

Public
9.8 KB · 260 lines swift
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
Files · ce73f14a9dd1fe80ae09bf638fcba97f1a74e08a
Menu

Theme