SPB Git

spb/ka-macos

Public
14.7 KB · 372 lines swift
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 » (bordure encre + ombre décalée)2526struct KACard: ViewModifier {27    @Environment(\.colorScheme) private var scheme28    var accent: Color? = nil2930    func body(content: Content) -> some View {31        content32            .background(KATheme.surface(scheme))33            .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))34            .overlay(35                RoundedRectangle(cornerRadius: 12, style: .continuous)36                    .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.3)37            )38            .background(39                RoundedRectangle(cornerRadius: 12, style: .continuous)40                    .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16))41                    .offset(x: 4, y: 4)42            )43            .padding(.trailing, 4).padding(.bottom, 4)44    }45}4647extension View {48    func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }49}5051// MARK: - Wordmark (« Lou » + boîte [Ka] accent)5253struct KAWordmark: View {54    let universe: Universe55    var size: CGFloat = 2056    @Environment(\.colorScheme) private var scheme5758    var body: some View {59        let parts = universe.wordmark.split(separator: "·", maxSplits: 1)60        HStack(alignment: .firstTextBaseline, spacing: 3) {61            Text(parts.first.map(String.init) ?? universe.wordmark)62                .font(.system(size: size, weight: .bold, design: .rounded))63            if parts.count > 1 {64                Text(String(parts[1]))65                    .font(.system(size: size * 0.86, weight: .bold, design: .rounded))66                    .foregroundStyle(universe.accent)67                    .padding(.horizontal, size * 0.28)68                    .padding(.vertical, size * 0.08)69                    .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))70                    .rotationEffect(.degrees(-2))71            }72        }73        .foregroundStyle(KATheme.ink(scheme))74        .fixedSize()75        .accessibilityLabel(universe.name)76    }77}7879struct GroupeKAMark: View {80    var size: CGFloat = 2481    @Environment(\.colorScheme) private var scheme82    var body: some View {83        HStack(alignment: .firstTextBaseline, spacing: 4) {84            Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded))85            Text("KA")86                .font(.system(size: size * 0.9, weight: .bold, design: .rounded))87                .foregroundStyle(KATheme.lime)88                .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)89                .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))90                .rotationEffect(.degrees(-2))91        }92        .fixedSize()93        .foregroundStyle(KATheme.ink(scheme))94        .accessibilityLabel("Groupe KA")95    }96}9798// MARK: - Puce mono (klabel)99100struct KAChip: View {101    let text: String102    var accent: Color? = nil103    @Environment(\.colorScheme) private var scheme104    var body: some View {105        Text(text)106            .font(.system(.caption2, design: .monospaced).weight(.bold))107            .textCase(.uppercase)108            .padding(.horizontal, 9).padding(.vertical, 4)109            .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())110            .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))111    }112}113114// MARK: - Pastille de statut (vert/rouge/en cours)115116struct KAStatusDot: View {117    let up: Bool?118    var body: some View {119        Circle()120            .fill(up == nil ? Color.gray.opacity(0.4) : (up! ? Color(hex: "#2f9e44") : Color(hex: "#e03131")))121            .frame(width: 9, height: 9)122            .accessibilityLabel(up == nil ? "Vérification" : (up! ? "En ligne" : "Hors ligne"))123    }124}125126// MARK: - Rangée d'item universelle127128struct KAItemRow: View {129    let item: KAItem130    var compact = false131    @Environment(\.colorScheme) private var scheme132    @EnvironmentObject private var favorites: FavoritesStore133134    private var universe: Universe? { Ecosystem.universe(item.universeID) }135136    var body: some View {137        HStack(alignment: .top, spacing: 12) {138            if let img = item.imageURL {139                KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")140                    .frame(width: compact ? 42 : 66, height: compact ? 42 : 66)141                    .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))142                    .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous)143                        .strokeBorder(.primary.opacity(0.25), lineWidth: 1))144                    .accessibilityHidden(true)145            } else {146                RoundedRectangle(cornerRadius: 8, style: .continuous)147                    .fill(universe?.accent.opacity(0.9) ?? .gray)148                    .frame(width: 5)149                    .padding(.vertical, 2)150            }151            VStack(alignment: .leading, spacing: 3) {152                Text(item.title)153                    .font(compact ? .subheadline.weight(.semibold) : .headline)154                    .lineLimit(compact ? 1 : 2)155                if let sub = item.subtitle, !sub.isEmpty {156                    Text(sub).font(compact ? .caption : .subheadline)157                        .foregroundStyle(KATheme.ink2(scheme)).lineLimit(compact ? 1 : 2)158                }159                HStack(spacing: 8) {160                    if let price = item.priceLabel {161                        Text(price).font(.system(compact ? .caption : .subheadline, design: .rounded).weight(.bold))162                            .foregroundStyle(universe?.accent ?? .primary)163                            .lineLimit(1)164                    }165                    if let city = item.city, !city.isEmpty {166                        Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)167                    }168                    Spacer(minLength: 0)169                    if let u = universe, !compact {170                        KAChip(text: u.wordmark, accent: u.accent)171                    }172                }173            }174            if favorites.isFavorite(item) {175                Image(systemName: "heart.fill")176                    .foregroundStyle(.red).font(.caption)177                    .accessibilityLabel("Dans vos favoris")178            }179        }180        .padding(compact ? 8 : 12)181        .frame(maxWidth: .infinity, alignment: .leading)182        .kaCard(accent: universe?.accent)183    }184}185186// MARK: - Carte d'item (grille photo, grandes fenêtres)187188struct KAItemCard: View {189    let item: KAItem190    var selected = false191    @Environment(\.colorScheme) private var scheme192    @EnvironmentObject private var favorites: FavoritesStore193194    private var universe: Universe? { Ecosystem.universe(item.universeID) }195196    var body: some View {197        VStack(alignment: .leading, spacing: 0) {198            ZStack(alignment: .topTrailing) {199                KAImage(url: item.imageURL, accent: universe?.accent ?? .gray,200                        symbol: universe?.symbol ?? "photo")201                    .frame(height: 130)202                    .frame(maxWidth: .infinity)203                    .clipped()204                if favorites.isFavorite(item) {205                    Image(systemName: "heart.fill")206                        .font(.caption).foregroundStyle(.red)207                        .padding(6)208                        .background(.ultraThinMaterial, in: Circle())209                        .padding(6)210                        .accessibilityLabel("Dans vos favoris")211                }212                if item.imageURLs.count > 1 {213                    Text("\(item.imageURLs.count) 📷")214                        .font(.system(size: 9, weight: .bold, design: .monospaced))215                        .padding(.horizontal, 6).padding(.vertical, 3)216                        .background(.ultraThinMaterial, in: Capsule())217                        .padding(6)218                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)219                }220            }221            VStack(alignment: .leading, spacing: 4) {222                Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)223                if let sub = item.subtitle, !sub.isEmpty {224                    Text(sub).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)225                }226                HStack(spacing: 6) {227                    if let p = item.priceLabel {228                        Text(p).font(.system(.caption, design: .rounded).weight(.bold))229                            .foregroundStyle(universe?.accent ?? .primary)230                            .lineLimit(1)231                    }232                    Spacer(minLength: 0)233                    if let c = item.city, !c.isEmpty {234                        Text(c).font(.system(size: 9, weight: .semibold, design: .monospaced))235                            .foregroundStyle(.secondary).lineLimit(1)236                    }237                }238            }239            .padding(10)240        }241        .background(KATheme.surface(scheme))242        .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))243        .overlay(244            RoundedRectangle(cornerRadius: 12, style: .continuous)245                .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme))246                              : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85),247                              lineWidth: selected ? 2.6 : 1.3)248        )249        .background(250            RoundedRectangle(cornerRadius: 12, style: .continuous)251                .fill((universe?.accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16))252                .offset(x: 4, y: 4)253        )254        .padding(.trailing, 4).padding(.bottom, 4)255    }256}257258// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé)259260struct KAImage: View {261    let url: URL?262    var accent: Color = .gray263    var symbol: String = "photo"264265    var body: some View {266        AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in267            switch phase {268            case .success(let image):269                image.resizable().aspectRatio(contentMode: .fill)270                    .transition(.opacity)271            case .failure:272                accent.opacity(0.1)273                    .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6)))274            default:275                accent.opacity(0.08)276                    .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35)))277            }278        }279    }280}281282// MARK: - Pression (échelle + réactivité, sans haptique sur Mac)283284struct KAPressStyle: ButtonStyle {285    func makeBody(configuration: Configuration) -> some View {286        configuration.label287            .contentShape(Rectangle())288            .scaleEffect(configuration.isPressed ? 0.98 : 1)289            .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)290    }291}292293// MARK: - Squelette de chargement (liste)294295struct KASkeletonRow: View {296    var body: some View {297        HStack(alignment: .top, spacing: 12) {298            RoundedRectangle(cornerRadius: 9).fill(.quaternary).frame(width: 66, height: 66)299            VStack(alignment: .leading, spacing: 8) {300                RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14)301                RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11)302                RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11)303            }304        }305        .padding(12).frame(maxWidth: .infinity, alignment: .leading).kaCard()306        .redacted(reason: .placeholder)307        .shimmering()308    }309}310311struct Shimmer: ViewModifier {312    @State private var on = false313    func body(content: Content) -> some View {314        content315            .opacity(on ? 0.5 : 0.9)316            .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on)317            .onAppear { on = true }318    }319}320extension View { func shimmering() -> some View { modifier(Shimmer()) } }321322// MARK: - États323324struct KAEmptyState: View {325    let symbol: String326    let title: String327    var message: String? = nil328    var body: some View {329        VStack(spacing: 10) {330            Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)331            Text(title).font(.headline)332            if let m = message {333                Text(m).font(.subheadline).foregroundStyle(.secondary)334                    .multilineTextAlignment(.center)335            }336        }337        .padding(30)338        .frame(maxWidth: .infinity)339    }340}341342// MARK: - Champ de recherche signature (bordure encre)343344struct KASearchField: View {345    let prompt: String346    @Binding var text: String347    var onSubmit: () -> Void348    @Environment(\.colorScheme) private var scheme349350    var body: some View {351        HStack(spacing: 8) {352            Image(systemName: "magnifyingglass").foregroundStyle(.secondary)353            TextField(prompt, text: $text)354                .textFieldStyle(.plain)355                .onSubmit(onSubmit)356            if !text.isEmpty {357                Button {358                    text = ""359                } label: {360                    Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)361                }362                .buttonStyle(.plain)363                .accessibilityLabel("Effacer")364            }365        }366        .padding(.horizontal, 11).padding(.vertical, 8)367        .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous))368        .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)369            .strokeBorder(KATheme.ink(scheme).opacity(0.5), lineWidth: 1.2))370    }371}372
Files · e2e01af1478899e68831e7da506cabe4fc68c49a
Menu

Theme