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)" }
GitRust