SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
16.3 KB · 418 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Theme.swift — design system Groupe-KA « éditorial sharp » adapté macOS :3// papier #f5f3ee / encre #141814, cartes bordure encre + ombre décalée,4// wordmark boîte accent, thème CLAIR par défaut.5// Adapté de l'app iOS KA (~/Desktop/KA/KA/Design/Theme.swift), sans UIKit.6import SwiftUI78// 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: - Carte « éditorial sharp » — tokens EXACTS des sites (tokens.css) :25// rayon 10 px, bordure encre 1,5 px, ombre décalée DURE 6×6 pleine encre.2627struct KACard: ViewModifier {28    @Environment(\.colorScheme) private var scheme29    var accent: Color? = nil3031    func body(content: Content) -> some View {32        content33            .background(KATheme.surface(scheme))34            .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))35            .overlay(36                RoundedRectangle(cornerRadius: 10, style: .continuous)37                    .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)38            )39            .background(40                RoundedRectangle(cornerRadius: 10, style: .continuous)41                    .fill(scheme == .dark42                          ? (accent ?? KATheme.inkDark).opacity(0.25)43                          : KATheme.inkLight)44                    .offset(x: 6, y: 6)45            )46            .padding(.trailing, 6).padding(.bottom, 6)47    }48}4950extension View {51    func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }52}5354// MARK: - Wordmark (« Lou » + boîte [Ka] accent)5556struct KAWordmark: View {57    let universe: Universe58    var size: CGFloat = 2059    @Environment(\.colorScheme) private var scheme6061    var body: some View {62        // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc63        let parts = universe.wordmark.split(separator: "·", maxSplits: 1)64        HStack(alignment: .firstTextBaseline, spacing: 3) {65            Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark)66                .font(KAFont.display(size))67            if parts.count > 1 {68                Text(String(parts[1]))69                    .font(KAFont.display(size * 0.86))70                    .foregroundStyle(.white)71                    .padding(.horizontal, size * 0.28)72                    .padding(.vertical, size * 0.08)73                    .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))74            }75        }76        .foregroundStyle(KATheme.ink(scheme))77        .fixedSize()78        .accessibilityLabel(universe.name)79    }80}8182struct GroupeKAMark: View {83    var size: CGFloat = 2484    @Environment(\.colorScheme) private var scheme85    var body: some View {86        HStack(alignment: .firstTextBaseline, spacing: 4) {87            Text("Groupe").font(KAFont.display(size))88            Text("KA")89                .font(KAFont.display(size * 0.9))90                .foregroundStyle(KATheme.lime)91                .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)92                .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))93                .rotationEffect(.degrees(-2))94        }95        .fixedSize()96        .foregroundStyle(KATheme.ink(scheme))97        .accessibilityLabel("Groupe KA")98    }99}100101// MARK: - Logo officiel d'une marque (PNG ka-ui embarqué, repli symbole SF)102103/// Vrai logo de la marque (tuile encre + « Ka » accent, rendu depuis ka-ui).104/// `id` = id d'univers (« lou-ka »…) ou « groupe-ka » pour le hub.105struct KALogo: View {106    let id: String107    var size: CGFloat = 20108    /// Repli si le PNG manque (ex. id inconnu) — symbole SF teinté accent.109    var fallbackSymbol: String = "square.grid.2x2"110    var fallbackAccent: Color = .gray111112    private static var cache: [String: NSImage] = [:]113114    static func image(_ id: String) -> NSImage? {115        if let hit = cache[id] { return hit }116        guard let url = Bundle.module.url(forResource: "logo-\(id)", withExtension: "png",117                                          subdirectory: "Logos"),118              let img = NSImage(contentsOf: url) else { return nil }119        cache[id] = img120        return img121    }122123    var body: some View {124        if let img = Self.image(id) {125            Image(nsImage: img)126                .resizable()127                .interpolation(.high)128                .aspectRatio(contentMode: .fit)129                .frame(width: size, height: size)130                .clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))131                .accessibilityHidden(true)132        } else {133            Image(systemName: fallbackSymbol)134                .font(.system(size: size * 0.6, weight: .semibold))135                .foregroundStyle(fallbackAccent)136                .frame(width: size, height: size)137                .accessibilityHidden(true)138        }139    }140}141142// MARK: - Puce mono (klabel)143144struct KAChip: View {145    let text: String146    var accent: Color? = nil147    @Environment(\.colorScheme) private var scheme148    var body: some View {149        Text(text)150            .font(KAFont.mono(10))151            .textCase(.uppercase)152            .padding(.horizontal, 9).padding(.vertical, 4)153            .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())154            .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))155    }156}157158// MARK: - Pastille de statut (vert/rouge/en cours)159160struct KAStatusDot: View {161    let up: Bool?162    var body: some View {163        Circle()164            .fill(up == nil ? Color.gray.opacity(0.4) : (up! ? Color(hex: "#2f9e44") : Color(hex: "#e03131")))165            .frame(width: 9, height: 9)166            .accessibilityLabel(up == nil ? "Vérification" : (up! ? "En ligne" : "Hors ligne"))167    }168}169170// MARK: - Rangée d'item universelle171172struct KAItemRow: View {173    let item: KAItem174    var compact = false175    @Environment(\.colorScheme) private var scheme176    @EnvironmentObject private var favorites: FavoritesStore177178    private var universe: Universe? { Ecosystem.universe(item.universeID) }179180    var body: some View {181        HStack(alignment: .top, spacing: 12) {182            if let img = item.imageURL {183                KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")184                    .frame(width: compact ? 42 : 66, height: compact ? 42 : 66)185                    .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))186                    .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous)187                        .strokeBorder(.primary.opacity(0.25), lineWidth: 1))188                    .accessibilityHidden(true)189            } else {190                RoundedRectangle(cornerRadius: 8, style: .continuous)191                    .fill(universe?.accent.opacity(0.9) ?? .gray)192                    .frame(width: 5)193                    .padding(.vertical, 2)194            }195            VStack(alignment: .leading, spacing: 3) {196                Text(item.title)197                    .font(compact ? .subheadline.weight(.semibold) : .headline)198                    .lineLimit(compact ? 1 : 2)199                if let sub = item.subtitle, !sub.isEmpty {200                    Text(sub).font(compact ? .caption : .subheadline)201                        .foregroundStyle(KATheme.ink2(scheme)).lineLimit(compact ? 1 : 2)202                }203                HStack(spacing: 8) {204                    if let price = item.priceLabel {205                        Text(price).font(.system(compact ? .caption : .subheadline, design: .rounded).weight(.bold))206                            .foregroundStyle(universe?.accent ?? .primary)207                            .lineLimit(1)208                    }209                    if let city = item.city, !city.isEmpty {210                        Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)211                    }212                    Spacer(minLength: 0)213                    if let u = universe, !compact {214                        KAChip(text: u.wordmark, accent: u.accent)215                    }216                }217            }218            if favorites.isFavorite(item) {219                Image(systemName: "heart.fill")220                    .foregroundStyle(.red).font(.caption)221                    .accessibilityLabel("Dans vos favoris")222            }223        }224        .padding(compact ? 8 : 12)225        .frame(maxWidth: .infinity, alignment: .leading)226        .kaCard(accent: universe?.accent)227    }228}229230// MARK: - Carte d'item (grille photo, grandes fenêtres)231232struct KAItemCard: View {233    let item: KAItem234    var selected = false235    @Environment(\.colorScheme) private var scheme236    @EnvironmentObject private var favorites: FavoritesStore237238    private var universe: Universe? { Ecosystem.universe(item.universeID) }239240    var body: some View {241        VStack(alignment: .leading, spacing: 0) {242            ZStack(alignment: .topTrailing) {243                KAImage(url: item.imageURL, accent: universe?.accent ?? .gray,244                        symbol: universe?.symbol ?? "photo")245                    .frame(height: 130)246                    .frame(maxWidth: .infinity)247                    .clipped()248                if favorites.isFavorite(item) {249                    Image(systemName: "heart.fill")250                        .font(.caption).foregroundStyle(.red)251                        .padding(6)252                        .background(.ultraThinMaterial, in: Circle())253                        .padding(6)254                        .accessibilityLabel("Dans vos favoris")255                }256                if item.imageURLs.count > 1 {257                    Text("\(item.imageURLs.count) 📷")258                        .font(.system(size: 9, weight: .bold, design: .monospaced))259                        .padding(.horizontal, 6).padding(.vertical, 3)260                        .background(.ultraThinMaterial, in: Capsule())261                        .padding(6)262                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)263                }264            }265            VStack(alignment: .leading, spacing: 4) {266                Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)267                if let sub = item.subtitle, !sub.isEmpty {268                    Text(sub).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)269                }270                HStack(spacing: 6) {271                    if let p = item.priceLabel {272                        Text(p).font(.system(.caption, design: .rounded).weight(.bold))273                            .foregroundStyle(universe?.accent ?? .primary)274                            .lineLimit(1)275                    }276                    Spacer(minLength: 0)277                    if let c = item.city, !c.isEmpty {278                        Text(c).font(.system(size: 9, weight: .semibold, design: .monospaced))279                            .foregroundStyle(.secondary).lineLimit(1)280                    }281                }282            }283            .padding(10)284        }285        .background(KATheme.surface(scheme))286        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))287        .overlay(288            RoundedRectangle(cornerRadius: 10, style: .continuous)289                .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme))290                              : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85),291                              lineWidth: selected ? 2.6 : 1.5)292        )293        .background(294            RoundedRectangle(cornerRadius: 10, style: .continuous)295                .fill(scheme == .dark296                      ? (universe?.accent ?? KATheme.inkDark).opacity(0.25)297                      : KATheme.inkLight)298                .offset(x: 6, y: 6)299        )300        .padding(.trailing, 6).padding(.bottom, 6)301    }302}303304// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé)305306struct KAImage: View {307    let url: URL?308    var accent: Color = .gray309    var symbol: String = "photo"310311    var body: some View {312        AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in313            switch phase {314            case .success(let image):315                image.resizable().aspectRatio(contentMode: .fill)316                    .transition(.opacity)317            case .failure:318                accent.opacity(0.1)319                    .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6)))320            default:321                accent.opacity(0.08)322                    .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35)))323            }324        }325    }326}327328// MARK: - Pression (échelle + réactivité, sans haptique sur Mac)329330struct KAPressStyle: ButtonStyle {331    func makeBody(configuration: Configuration) -> some View {332        configuration.label333            .contentShape(Rectangle())334            .scaleEffect(configuration.isPressed ? 0.98 : 1)335            .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)336    }337}338339// MARK: - Squelette de chargement (liste)340341struct KASkeletonRow: View {342    var body: some View {343        HStack(alignment: .top, spacing: 12) {344            RoundedRectangle(cornerRadius: 9).fill(.quaternary).frame(width: 66, height: 66)345            VStack(alignment: .leading, spacing: 8) {346                RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14)347                RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11)348                RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11)349            }350        }351        .padding(12).frame(maxWidth: .infinity, alignment: .leading).kaCard()352        .redacted(reason: .placeholder)353        .shimmering()354    }355}356357struct Shimmer: ViewModifier {358    @State private var on = false359    func body(content: Content) -> some View {360        content361            .opacity(on ? 0.5 : 0.9)362            .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on)363            .onAppear { on = true }364    }365}366extension View { func shimmering() -> some View { modifier(Shimmer()) } }367368// MARK: - États369370struct KAEmptyState: View {371    let symbol: String372    let title: String373    var message: String? = nil374    var body: some View {375        VStack(spacing: 10) {376            Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)377            Text(title).font(.headline)378            if let m = message {379                Text(m).font(.subheadline).foregroundStyle(.secondary)380                    .multilineTextAlignment(.center)381            }382        }383        .padding(30)384        .frame(maxWidth: .infinity)385    }386}387388// MARK: - Champ de recherche signature (bordure encre)389390struct KASearchField: View {391    let prompt: String392    @Binding var text: String393    var onSubmit: () -> Void394    @Environment(\.colorScheme) private var scheme395396    var body: some View {397        HStack(spacing: 8) {398            Image(systemName: "magnifyingglass").foregroundStyle(.secondary)399            TextField(prompt, text: $text)400                .textFieldStyle(.plain)401                .onSubmit(onSubmit)402            if !text.isEmpty {403                Button {404                    text = ""405                } label: {406                    Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)407                }408                .buttonStyle(.plain)409                .accessibilityLabel("Effacer")410            }411        }412        .padding(.horizontal, 11).padding(.vertical, 8)413        .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous))414        .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)415            .strokeBorder(KATheme.ink(scheme).opacity(0.5), lineWidth: 1.2))416    }417}418