CarnetScreen.swift 246 lignes · 9522 octets
import SwiftUI

// Port of ui/carnet/CarnetScreen.kt — header with sort menu + settings,
// Carnet | Projets tabs, FTS search, sectioned list with alphabet rail,
// square FAB stack (scan + add menu).

struct CarnetScreen: View {
    @ObservedObject var viewModel: CarnetViewModel
    let onOpenContact: (Int64, ContactSort) -> Void
    let onScan: () -> Void
    let onManual: () -> Void
    let onImportVcf: () -> Void
    let onSettings: () -> Void
    let onOpenProjets: () -> Void
    @ObservedObject var syncViewModel: SyncChromeViewModel

    var body: some View {
        VStack(spacing: 0) {
            header
            MainTabRow(current: .carnet) { tab in
                if tab == .projets { onOpenProjets() }
            }
            C2VTextField(label: "", placeholder: "Rechercher…", text: Binding(
                get: { viewModel.query },
                set: { viewModel.setQuery($0) }
            ))
            SyncBanner(
                pendingChanges: syncViewModel.pendingRemoteChanges,
                localAhead: syncViewModel.localCalendarAhead,
                syncing: syncViewModel.syncing,
                error: syncViewModel.error,
                onSyncClick: { syncViewModel.syncNow() },
                summary: syncViewModel.syncSummary
            )
            Spacer().frame(height: 8)
            content
        }
        .padding(.horizontal, 18)
        .background(C2VColor.fond)
        .overlay(alignment: .bottomTrailing) { fabStack }
        .syncConflictDialog(
            conflict: syncViewModel.conflictPending,
            onConfirm: { syncViewModel.confirmConflictCancellation() },
            onDismiss: { syncViewModel.dismissConflict() }
        )
        .toolbar(.hidden, for: .navigationBar)
        .task { await viewModel.refresh() }
        .onAppear { syncViewModel.refreshStatus() }
    }

    private var header: some View {
        HStack(spacing: 0) {
            Text("PicLead".uppercased())
                .font(C2VFont.titleLarge)
                .foregroundColor(C2VColor.ink)
            Spacer()
            if syncViewModel.configured {
                Button(action: { syncViewModel.syncNow() }) {
                    Image(systemName: "arrow.triangle.2.circlepath")
                        .foregroundColor(C2VColor.ink)
                        .frame(width: 44, height: 44)
                }
                .accessibilityLabel("Synchroniser")
            }
            Menu {
                ForEach(ContactSort.allCases, id: \.self) { option in
                    Button(sortLabel(option)) { viewModel.setSort(option) }
                }
            } label: {
                Text(sortLabel(viewModel.sort))
                    .font(C2VFont.labelMedium)
                    .foregroundColor(C2VColor.ink)
                    .padding(.vertical, 8)
                    .padding(.horizontal, 8)
            }
            Button(action: onSettings) {
                Image(systemName: "gearshape")
                    .foregroundColor(C2VColor.ink)
                    .frame(width: 44, height: 44)
            }
            .accessibilityLabel("Paramètres")
        }
        .padding(.top, 12)
        .padding(.bottom, 8)
    }

    @ViewBuilder
    private var content: some View {
        if viewModel.isEmpty {
            VStack {
                Spacer()
                Text("Aucun contact — scanner une carte ou importer un VCF")
                    .font(C2VFont.bodyLarge)
                    .foregroundColor(C2VColor.texteFaible)
                    .multilineTextAlignment(.center)
                Spacer()
            }
            .frame(maxWidth: .infinity)
        } else {
            let showRail = viewModel.query.isBlank && viewModel.sort != .createdAt
            ScrollViewReader { proxy in
                HStack(spacing: 0) {
                    contactList(showSectionHeaders: viewModel.sort != .createdAt)
                    if showRail {
                        AlphabetRail(presentLetters: viewModel.presentLetters) { letter in
                            withAnimation {
                                proxy.scrollTo("header-\(letter)", anchor: .top)
                            }
                        }
                    }
                }
            }
        }
    }

    private func contactList(showSectionHeaders: Bool) -> some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 0) {
                ForEach(viewModel.sections, id: \.letter) { section in
                    if showSectionHeaders {
                        Text(String(section.letter))
                            .font(C2VFont.labelMedium)
                            .foregroundColor(C2VColor.texteFaible)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.vertical, 6)
                            .background(C2VColor.fond)
                            .id("header-\(section.letter)")
                    }
                    ForEach(section.contacts, id: \.id) { contact in
                        ContactRow(
                            contact: contact,
                            duplicateCount: viewModel.duplicateCountById[contact.id] ?? 0,
                            badge: viewModel.badges[contact.id]
                        ) {
                            onOpenContact(contact.id, viewModel.sort)
                        }
                    }
                }
            }
        }
    }

    private var fabStack: some View {
        VStack(alignment: .trailing, spacing: 12) {
            Menu {
                Button("Saisie manuelle", action: onManual)
                Button("Import VCF", action: onImportVcf)
            } label: {
                Image(systemName: "plus")
                    .foregroundColor(C2VColor.ink)
                    .frame(width: 44, height: 44)
                    .background(C2VColor.fond)
                    .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
            }
            .accessibilityLabel("Autres ajouts")
            Button(action: onScan) {
                Image(systemName: "camera.fill")
                    .foregroundColor(C2VColor.onPrimary)
                    .frame(width: 56, height: 56)
                    .background(C2VColor.ink)
            }
            .accessibilityLabel("Scanner une carte")
        }
        .padding(.trailing, 18)
        .padding(.bottom, 24)
    }
}

private struct ContactRow: View {
    let contact: CrmContactEntity
    let duplicateCount: Int
    var badge: ContactSyncBadge? = nil
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            VStack(spacing: 0) {
                HStack(spacing: 10) {
                    // Pastille de synchro : bleu = en attente, rouge = échec.
                    if let badge {
                        Circle()
                            .fill(badge == .error ? C2VColor.brandError : C2VColor.link)
                            .frame(width: 8, height: 8)
                            .accessibilityLabel(
                                badge == .error ? "Échec de synchronisation" : "En attente de synchronisation"
                            )
                    }
                    thumbnail
                    VStack(alignment: .leading, spacing: 2) {
                        Text(contactDisplayName(contact))
                            .font(C2VFont.bodyLarge)
                            .foregroundColor(C2VColor.ink)
                        if let subtitle = contactSubtitle(contact) {
                            Text(subtitle)
                                .font(C2VFont.bodyMedium)
                                .foregroundColor(C2VColor.texteFaible)
                        }
                    }
                    Spacer()
                    if duplicateCount > 0 {
                        Text("\(duplicateCount)")
                            .font(C2VFont.labelSmall)
                            .foregroundColor(C2VColor.texteFaible)
                            .padding(4)
                            .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
                            .accessibilityLabel("\(duplicateCount) doublon(s)")
                    }
                }
                .padding(.vertical, 10)
                C2VDivider()
            }
        }
        .buttonStyle(.plain)
    }

    @ViewBuilder
    private var thumbnail: some View {
        if let path = contact.profileImagePath?.nilIfBlank, let ui = UIImage(contentsOfFile: path) {
            Image(uiImage: ui)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: 40, height: 40)
                .clipped()
                .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
        } else {
            Text(String(contactDisplayName(contact).prefix(1)).uppercased())
                .font(C2VFont.labelMedium)
                .foregroundColor(C2VColor.texteFaible)
                .frame(width: 40, height: 40)
                .background(C2VColor.canvasSoft)
        }
    }
}

extension ContactSort: CaseIterable {
    static var allCases: [ContactSort] { [.lastName, .firstName, .company, .createdAt] }
}

func sortLabel(_ sort: ContactSort) -> String {
    switch sort {
    case .lastName: return "Nom"
    case .firstName: return "Prénom"
    case .company: return "Entreprise"
    case .createdAt: return "Date"
    }
}