Theme.swift 207 lignes · 7928 octets
import SwiftUI

// Port of android ui/theme/{Color,Type,Theme}.kt.
// Palette PicLead — « ailiance brain » charte from the server (Server/tailwind.config.js):
// white canvas, near-black ink, cool grays, mint accent, dedicated error red.
// Link blue stays reserved for the « pending sync » dot (user spec).

enum C2VColor {
    static let ink = Color(hexRGB: 0x0F1115)
    static let charcoal = Color(hexRGB: 0x1F2937)
    static let slate = Color(hexRGB: 0x475467)
    static let steel = Color(hexRGB: 0x667085)
    static let stone = Color(hexRGB: 0x98A2B3)
    static let canvas = Color(hexRGB: 0xFFFFFF)
    static let surfaceTeinte = Color(hexRGB: 0xF4F5F7)
    static let surfaceSoft = Color(hexRGB: 0xFAFBFC)
    static let hairline = Color(hexRGB: 0xE4E6EA)

    static let brandGreen = Color(hexRGB: 0x00D4A4)
    static let brandGreenDeep = Color(hexRGB: 0x00B389)
    static let brandGreenSoft = Color(hexRGB: 0xE6FAF4)
    static let brandError = Color(hexRGB: 0xE5484D)
    static let brandErrorSoft = Color(hexRGB: 0xFDECEA)
    static let brandWarn = Color(hexRGB: 0xE89642)

    /// Blue reserved for the « not synchronized » dot (user spec).
    static let link = Color(hexRGB: 0x057DBC)
    static let onPrimary = Color(hexRGB: 0xFFFFFF)

    // Historical aliases used by the migrated screens (Kotlin: Encre/Fond/…).
    static let inkSoft = charcoal
    static let body = steel
    static let canvasSoft = surfaceTeinte
    static let encre = ink
    static let fond = canvas
    static let surface = canvas
    static let bordure = hairline
    static let texteFaible = steel
}

private extension Color {
    init(hexRGB: UInt32) {
        self.init(
            red: Double((hexRGB >> 16) & 0xFF) / 255.0,
            green: Double((hexRGB >> 8) & 0xFF) / 255.0,
            blue: Double(hexRGB & 0xFF) / 255.0
        )
    }
}

// Android bundled Playfair (display serif), Lora (body serif) and Manrope
// (sans labels). No fonts ship in the iOS target, so the system serif/sans
// designs carry the same editorial split: serif for narrative, sans for labels.
enum C2VFont {
    static let displayLarge = Font.system(size: 40, weight: .regular, design: .serif)
    static let displayMedium = Font.system(size: 32, weight: .regular, design: .serif)
    static let headlineMedium = Font.system(size: 26, weight: .regular, design: .serif)
    static let titleLarge = Font.system(size: 22, weight: .regular, design: .serif)
    static let titleMedium = Font.system(size: 16, weight: .bold)
    static let bodyLarge = Font.system(size: 16, weight: .regular, design: .serif)
    static let bodyMedium = Font.system(size: 14, weight: .regular)
    static let bodySmall = Font.system(size: 12, weight: .regular)
    static let labelLarge = Font.system(size: 16, weight: .bold)
    static let labelMedium = Font.system(size: 14, weight: .bold)
    static let labelSmall = Font.system(size: 12, weight: .bold)
}

/// design.md `button-primary`: square black CTA, white label.
struct C2VPrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(C2VFont.labelLarge)
            .foregroundColor(C2VColor.onPrimary)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 12)
            .padding(.horizontal, 20)
            .background(C2VColor.ink.opacity(configuration.isPressed ? 0.8 : 1))
    }
}

/// design.md `button-outline`: white square with 1 px border, ink label.
struct C2VOutlineButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(C2VFont.labelLarge)
            .foregroundColor(C2VColor.ink)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 12)
            .padding(.horizontal, 20)
            .background(configuration.isPressed ? C2VColor.canvasSoft : C2VColor.canvas)
            .overlay(Rectangle().stroke(C2VColor.hairline, lineWidth: 1))
    }
}

/// Compose `TextButton` with square shape — bare ink label.
struct C2VTextButtonStyle: ButtonStyle {
    var color: Color = C2VColor.ink

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(C2VFont.labelMedium)
            .foregroundColor(color.opacity(configuration.isPressed ? 0.6 : 1))
            .padding(.vertical, 8)
            .padding(.horizontal, 8)
    }
}

/// 1 px hairline divider (design.md `hairline-divider`).
struct C2VDivider: View {
    var body: some View {
        Rectangle()
            .fill(C2VColor.hairline)
            .frame(height: 1)
    }
}

/// Compose `AlertDialog` with square shape: dimmed backdrop (tap = dismiss request),
/// square card on `Fond` with title, content and trailing text buttons. Stays
/// presented until the caller clears its trigger state, so inline errors survive
/// a failed confirm (system alerts always dismiss on any button).
struct C2VDialog<DialogContent: View>: View {
    let title: String
    let confirmLabel: String
    let dismissLabel: String
    let onConfirm: () -> Void
    let onDismiss: () -> Void
    @ViewBuilder var content: () -> DialogContent

    var body: some View {
        ZStack {
            Color.black.opacity(0.35)
                .ignoresSafeArea()
                .onTapGesture(perform: onDismiss)
            VStack(alignment: .leading, spacing: 12) {
                Text(title)
                    .font(C2VFont.titleMedium)
                    .foregroundColor(C2VColor.ink)
                content()
                HStack(spacing: 4) {
                    Spacer()
                    Button(dismissLabel, action: onDismiss)
                        .buttonStyle(C2VTextButtonStyle())
                    Button(confirmLabel, action: onConfirm)
                        .buttonStyle(C2VTextButtonStyle())
                }
            }
            .padding(20)
            .background(C2VColor.fond)
            .overlay(Rectangle().stroke(C2VColor.hairline, lineWidth: 1))
            .padding(.horizontal, 32)
        }
    }
}

/// Square outlined text field, hairline border, ink border when focused
/// (Compose `OutlinedTextField` with the Card2vcf color set).
struct C2VTextField: View {
    let label: String
    var placeholder: String = ""
    @Binding var text: String
    var minLines: Int = 1
    /// Hint under the field (Compose `supportingText`), e.g. « À vérifier — lecture incertaine ».
    var supportingText: String? = nil
    var isError: Bool = false

    @FocusState private var focused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            if !label.isEmpty {
                Text(label)
                    .font(C2VFont.labelSmall)
                    .foregroundColor(C2VColor.body)
            }
            field
                .textFieldStyle(.plain)
                .font(C2VFont.bodyLarge)
                .foregroundColor(C2VColor.ink)
                .tint(C2VColor.ink)
                .focused($focused)
                .padding(.horizontal, 12)
                .padding(.vertical, 10)
                .background(C2VColor.canvas)
                .overlay(
                    Rectangle().stroke(
                        isError ? C2VColor.brandError : (focused ? C2VColor.ink : C2VColor.hairline),
                        lineWidth: 1
                    )
                )
            if let supportingText {
                Text(supportingText)
                    .font(C2VFont.bodySmall)
                    .foregroundColor(isError ? C2VColor.brandError : C2VColor.brandWarn)
            }
        }
    }

    @ViewBuilder
    private var field: some View {
        if minLines > 1 {
            TextField(placeholder, text: $text, axis: .vertical)
                .lineLimit(minLines...)
        } else {
            TextField(placeholder, text: $text)
        }
    }
}