DuplicateReviewScreen.swift 346 lignes · 12773 octets
import SwiftUI

// Port of ui/contact/DuplicateReviewScreen.kt — cluster review (select,
// delete, mark different, merge) + field-by-field merge panel.

struct DuplicateReviewScreen: View {
    @StateObject private var viewModel: DuplicateReviewViewModel
    let onBack: () -> Void
    let onMerged: (Int64) -> Void

    init(
        contactId: Int64,
        onBack: @escaping () -> Void,
        onMerged: @escaping (Int64) -> Void
    ) {
        _viewModel = StateObject(wrappedValue: DuplicateReviewViewModel(contactId: contactId))
        self.onBack = onBack
        self.onMerged = onMerged
    }

    var body: some View {
        VStack(spacing: 0) {
            ScreenTopBar(
                title: viewModel.merging ? "Fusionner" : "Doublons",
                centered: false,
                onBack: {
                    if viewModel.merging {
                        viewModel.cancelMerge()
                    } else {
                        onBack()
                    }
                }
            )
            content
        }
        .background(C2VColor.fond)
        .toolbar(.hidden, for: .navigationBar)
        .task { await viewModel.refresh() }
        .onChange(of: viewModel.mergeDoneKeepId) { keepId in
            if let keepId { onMerged(keepId) }
        }
    }

    @ViewBuilder
    private var content: some View {
        let cluster = viewModel.cluster
        let primary = cluster.first { $0.id == viewModel.contactId }
        let others = cluster.filter { $0.id != viewModel.contactId }

        if viewModel.merging {
            let sources = viewModel.mergeSources(cluster)
            if sources.count < 2 {
                emptyState
            } else {
                MergePanel(
                    sources: sources,
                    initial: viewModel.defaultChoices(sources),
                    onConfirm: { choices in
                        viewModel.confirmMerge(sources: sources, choices: choices)
                    },
                    onCancel: { viewModel.cancelMerge() }
                )
            }
        } else if others.isEmpty {
            emptyState
        } else {
            ScrollView {
                VStack(alignment: .leading, spacing: 12) {
                    if let primary {
                        Text("Contact courant")
                            .font(C2VFont.labelMedium)
                            .foregroundColor(C2VColor.texteFaible)
                        ContactSummaryRow(contact: primary, selected: false, onToggle: nil)
                        C2VDivider()
                    }
                    Text("Contacts similaires")
                        .font(C2VFont.labelMedium)
                        .foregroundColor(C2VColor.texteFaible)
                    ForEach(others, id: \.id) { c in
                        ContactSummaryRow(
                            contact: c,
                            selected: viewModel.selectedIds.contains(c.id),
                            onToggle: { viewModel.toggleSelect(c.id) }
                        )
                    }
                }
                .padding(.horizontal, 18)
                .padding(.vertical, 12)
            }
            VStack(spacing: 8) {
                let hasSelection = !viewModel.selectedIds.isEmpty
                Button("Supprimer la sélection") { viewModel.deleteSelected() }
                    .buttonStyle(C2VOutlineButtonStyle())
                    .disabled(!hasSelection)
                Button("Contacts différents") { viewModel.markSelectedDifferent() }
                    .buttonStyle(C2VOutlineButtonStyle())
                    .disabled(!hasSelection)
                Button("Fusionner") { viewModel.startMerge() }
                    .buttonStyle(C2VPrimaryButtonStyle())
            }
            .padding(.horizontal, 18)
            .padding(.vertical, 12)
        }
    }

    private var emptyState: some View {
        VStack {
            Spacer()
            Text("Aucun doublon")
                .font(C2VFont.bodyMedium)
                .foregroundColor(C2VColor.texteFaible)
            Spacer()
        }
        .frame(maxWidth: .infinity)
    }
}

private struct ContactSummaryRow: View {
    let contact: CrmContactEntity
    let selected: Bool
    let onToggle: (() -> Void)?

    var body: some View {
        let row = HStack(spacing: 10) {
            if onToggle != nil {
                Image(systemName: selected ? "checkmark.square.fill" : "square")
                    .foregroundColor(C2VColor.ink)
            }
            VStack(alignment: .leading, spacing: 2) {
                Text(mergeDisplayName(contact))
                    .font(C2VFont.bodyLarge)
                    .foregroundColor(C2VColor.encre)
                let detail = [
                    contact.emails.first?.nilIfBlank,
                    contact.phones.first?.nilIfBlank,
                    contact.company?.nilIfBlank
                ]
                .compactMap { $0 }
                .joined(separator: " · ")
                if !detail.isEmpty {
                    Text(detail)
                        .font(C2VFont.bodySmall)
                        .foregroundColor(C2VColor.texteFaible)
                }
            }
            Spacer(minLength: 0)
        }
        .padding(12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))

        if let onToggle {
            Button(action: onToggle) { row }
                .buttonStyle(.plain)
        } else {
            row
        }
    }
}

private struct MergePanel: View {
    let sources: [CrmContactEntity]
    let onConfirm: (MergeFieldChoices) -> Void
    let onCancel: () -> Void

    @State private var choices: MergeFieldChoices

    init(
        sources: [CrmContactEntity],
        initial: MergeFieldChoices,
        onConfirm: @escaping (MergeFieldChoices) -> Void,
        onCancel: @escaping () -> Void
    ) {
        self.sources = sources
        self.onConfirm = onConfirm
        self.onCancel = onCancel
        _choices = State(initialValue: initial)
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 14) {
                Text("Conserver la fiche")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
                ForEach(sources, id: \.id) { c in
                    RadioRow(label: mergeDisplayName(c), selected: choices.keepId == c.id) {
                        choices.keepId = c.id
                    }
                }

                C2VDivider()

                fieldPicker("Nom complet", selectedId: choices.fullNameFromId, valueOf: { $0.fullName }) {
                    choices.fullNameFromId = $0
                }
                fieldPicker("Prénom", selectedId: choices.firstNameFromId, valueOf: { $0.firstName }) {
                    choices.firstNameFromId = $0
                }
                fieldPicker("Nom de famille", selectedId: choices.lastNameFromId, valueOf: { $0.lastName }) {
                    choices.lastNameFromId = $0
                }
                fieldPicker("Société", selectedId: choices.companyFromId, valueOf: { $0.company }) {
                    choices.companyFromId = $0
                }
                fieldPicker("Poste", selectedId: choices.jobTitleFromId, valueOf: { $0.jobTitle }) {
                    choices.jobTitleFromId = $0
                }
                fieldPicker("Site web", selectedId: choices.websiteFromId, valueOf: { $0.website }) {
                    choices.websiteFromId = $0
                }
                fieldPicker("Adresse", selectedId: choices.addressFromId, valueOf: { $0.address }) {
                    choices.addressFromId = $0
                }

                Text("Notes")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
                RadioRow(
                    label: "Garder celles de la fiche conservée",
                    selected: choices.notesMode == .keepPrimary
                ) {
                    choices.notesMode = .keepPrimary
                }
                RadioRow(label: "Concaténer toutes les notes", selected: choices.notesMode == .concat) {
                    choices.notesMode = .concat
                }

                imagePicker(
                    "Photo de la carte",
                    selectedId: choices.cardImageFromId,
                    pathOf: { $0.cardImagePath }
                ) {
                    choices.cardImageFromId = $0
                }
                imagePicker(
                    "Photo de profil",
                    selectedId: choices.profileImageFromId,
                    pathOf: { $0.profileImagePath }
                ) {
                    choices.profileImageFromId = $0
                }

                Spacer().frame(height: 8)
                Button("Annuler", action: onCancel)
                    .buttonStyle(C2VOutlineButtonStyle())
                Button("Confirmer la fusion") { onConfirm(choices) }
                    .buttonStyle(C2VPrimaryButtonStyle())
            }
            .padding(.horizontal, 18)
            .padding(.vertical, 12)
        }
    }

    private func fieldPicker(
        _ label: String,
        selectedId: Int64,
        valueOf: @escaping (CrmContactEntity) -> String?,
        onSelect: @escaping (Int64) -> Void
    ) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(label)
                .font(C2VFont.labelMedium)
                .foregroundColor(C2VColor.texteFaible)
            ForEach(sources, id: \.id) { c in
                let value = valueOf(c)?.nilIfBlank ?? "—"
                RadioRow(label: "\(mergeDisplayName(c)): \(value)", selected: selectedId == c.id) {
                    onSelect(c.id)
                }
            }
        }
    }

    @ViewBuilder
    private func imagePicker(
        _ label: String,
        selectedId: Int64?,
        pathOf: @escaping (CrmContactEntity) -> String?,
        onSelect: @escaping (Int64?) -> Void
    ) -> some View {
        let withImage = sources.filter { pathOf($0)?.nilIfBlank != nil }
        if !withImage.isEmpty {
            VStack(alignment: .leading, spacing: 6) {
                Text(label)
                    .font(C2VFont.labelMedium)
                    .foregroundColor(C2VColor.texteFaible)
                RadioRow(label: "Aucune", selected: selectedId == nil) {
                    onSelect(nil)
                }
                ForEach(withImage, id: \.id) { c in
                    Button {
                        onSelect(c.id)
                    } label: {
                        HStack(spacing: 10) {
                            Image(systemName: selectedId == c.id ? "circle.inset.filled" : "circle")
                                .foregroundColor(C2VColor.ink)
                            PathImage(path: pathOf(c))
                                .frame(width: 48, height: 48)
                                .clipped()
                                .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
                            Text(mergeDisplayName(c))
                                .font(C2VFont.bodyMedium)
                                .foregroundColor(C2VColor.encre)
                            Spacer(minLength: 0)
                        }
                        .padding(.vertical, 4)
                    }
                    .buttonStyle(.plain)
                }
            }
        }
    }
}

private struct RadioRow: View {
    let label: String
    let selected: Bool
    let onSelect: () -> Void

    var body: some View {
        Button(action: onSelect) {
            HStack(spacing: 8) {
                Image(systemName: selected ? "circle.inset.filled" : "circle")
                    .foregroundColor(C2VColor.ink)
                Text(label)
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.encre)
                    .multilineTextAlignment(.leading)
                Spacer(minLength: 0)
            }
            .padding(.vertical, 2)
        }
        .buttonStyle(.plain)
    }
}

// Kotlin `displayName` in DuplicateReviewScreen falls back to "#<id>".
private func mergeDisplayName(_ c: CrmContactEntity) -> String {
    if let full = c.fullName?.nilIfBlank { return full }
    let composed = [c.firstName, c.lastName].compactMap { $0 }
        .joined(separator: " ")
        .trimmingCharacters(in: .whitespaces)
    if !composed.isEmpty { return composed }
    if let company = c.company?.nilIfBlank { return company }
    return "#\(c.id)"
}