ContactPagerScreen.swift 448 lignes · 17266 octets
import SwiftUI

// Port of ui/contact/ContactPagerScreen.kt — swipeable pager of contact
// fiches. Adds the iOS actions: export VCF, system contact, delete with
// confirmation, inline notes editor.

struct ContactPagerScreen: View {
    @StateObject private var viewModel: ContactPagerViewModel
    let onBack: () -> Void
    let onEdit: (Int64) -> Void
    let onDuplicates: (Int64) -> Void
    let onDeleted: () -> Void

    @State private var pageIndex: Int = 0
    @State private var initialized = false

    init(
        contactId: Int64,
        sort: ContactSort,
        onBack: @escaping () -> Void,
        onEdit: @escaping (Int64) -> Void,
        onDuplicates: @escaping (Int64) -> Void,
        onDeleted: @escaping () -> Void
    ) {
        _viewModel = StateObject(
            wrappedValue: ContactPagerViewModel(initialContactId: contactId, sort: sort)
        )
        self.onBack = onBack
        self.onEdit = onEdit
        self.onDuplicates = onDuplicates
        self.onDeleted = onDeleted
    }

    var body: some View {
        VStack(spacing: 0) {
            if viewModel.contacts.isEmpty {
                topBar(current: nil)
                Spacer()
                Text("Aucun contact — scanner une carte ou importer un VCF")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
                Spacer()
            } else {
                let current = viewModel.contacts.indices.contains(pageIndex)
                    ? viewModel.contacts[pageIndex]
                    : viewModel.contacts[0]
                topBar(current: current)
                TabView(selection: $pageIndex) {
                    ForEach(Array(viewModel.contacts.enumerated()), id: \.element.id) { index, contact in
                        ContactFichePage(
                            contact: contact,
                            onSaveNotes: { notes in
                                Task { await viewModel.saveNotes(contactId: contact.id, notes: notes) }
                            },
                            onDelete: {
                                Task {
                                    await viewModel.delete(contactId: contact.id)
                                    onDeleted()
                                }
                            }
                        )
                        .tag(index)
                    }
                }
                .tabViewStyle(.page(indexDisplayMode: .never))
            }
        }
        .background(C2VColor.fond)
        .toolbar(.hidden, for: .navigationBar)
        .task {
            await viewModel.refresh()
            if !initialized {
                pageIndex = viewModel.initialPageIndex(viewModel.contacts)
                initialized = true
            }
        }
    }

    @ViewBuilder
    private func topBar(current: CrmContactEntity?) -> some View {
        HStack(spacing: 0) {
            Button(action: onBack) {
                Image(systemName: "arrow.left")
                    .foregroundColor(C2VColor.ink)
                    .frame(width: 44, height: 44)
            }
            .accessibilityLabel("Retour")
            Spacer()
            if let current {
                let dupCount = viewModel.duplicateCountById[current.id] ?? 0
                if dupCount > 0 {
                    Button(String(format: "%d doublon(s)", dupCount)) {
                        onDuplicates(current.id)
                    }
                    .buttonStyle(C2VTextButtonStyle(color: C2VColor.encre))
                }
                Button("Éditer") { onEdit(current.id) }
                    .buttonStyle(C2VTextButtonStyle(color: C2VColor.encre))
            }
        }
        .padding(.horizontal, 4)
        .padding(.vertical, 4)
        .background(C2VColor.surface)
    }
}

private struct ContactFichePage: View {
    let contact: CrmContactEntity
    let onSaveNotes: (String) -> Void
    let onDelete: () -> Void

    @State private var notesDraft: String
    @State private var showSystemContact = false
    @State private var confirmDelete = false
    @State private var interactionsNoteVocale: [InteractionEntity] = []
    @State private var afficherEnregistrement = false
    @State private var relanceErreur: String?

    init(
        contact: CrmContactEntity,
        onSaveNotes: @escaping (String) -> Void,
        onDelete: @escaping () -> Void
    ) {
        self.contact = contact
        self.onSaveNotes = onSaveNotes
        self.onDelete = onDelete
        _notesDraft = State(initialValue: contact.notes ?? "")
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                PathImage(path: contact.profileImagePath, revision: contact.updatedAt)
                    .frame(width: 96, height: 96)
                    .clipped()
                    .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))

                Text(contactDisplayName(contact))
                    .font(C2VFont.titleLarge)
                    .foregroundColor(C2VColor.encre)

                fieldBlock("Prénom", contact.firstName)
                fieldBlock("Nom de famille", contact.lastName)
                fieldBlock("Société", contact.company)
                fieldBlock("Poste", contact.jobTitle)
                clickableFieldBlock("Téléphone(s)", values: phones) { ExternalLinks.dial($0) }
                clickableFieldBlock("E-mail(s)", values: emails) { ExternalLinks.email($0) }
                clickableFieldBlock(
                    "Site web",
                    values: [contact.website?.nilIfBlank].compactMap { $0 }
                ) { ExternalLinks.web($0) }
                fieldBlock("Adresse", contact.address)

                if let cardPath = contact.cardImagePath?.nilIfBlank {
                    C2VDivider()
                    PathImage(path: cardPath, contentMode: .fit, revision: contact.updatedAt)
                        .frame(maxWidth: .infinity)
                        .frame(height: 220)
                        .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
                }

                C2VDivider()
                notesEditor

                C2VDivider()
                notesVocalesSection

                C2VDivider()
                actions
            }
            .padding(.horizontal, 18)
            .padding(.vertical, 12)
        }
        .sheet(isPresented: $showSystemContact) {
            SystemContactView(card: contact.toCard()) {
                showSystemContact = false
            }
        }
        .confirmationDialog(
            "Supprimer ce contact ?",
            isPresented: $confirmDelete,
            titleVisibility: .visible
        ) {
            Button("Supprimer", role: .destructive, action: onDelete)
            Button("Annuler", role: .cancel) {}
        }
        .sheet(isPresented: $afficherEnregistrement) {
            let contactServerId = contact.serverId
            EnregistrementNoteSheet { sujet, desc, path, transcription in
                await ContactFichePage.sauvegarderInteractionVocale(
                    contactServerId: contactServerId,
                    sujet: sujet, description: desc,
                    audioPath: path, demandeTranscription: transcription
                )
            }
        }
        .task(id: contact.id) {
            await chargerInteractions()
        }
    }

    private var phones: [String] { contact.phones.filter { $0.isNotBlank } }
    private var emails: [String] { contact.emails.filter { $0.isNotBlank } }

    private var notesEditor: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("Note")
                .font(C2VFont.labelMedium)
                .foregroundColor(C2VColor.texteFaible)
            TextEditor(text: $notesDraft)
                .font(C2VFont.bodyLarge)
                .foregroundColor(C2VColor.encre)
                .tint(C2VColor.ink)
                .frame(minHeight: 72)
                .scrollContentBackground(.hidden)
                .background(C2VColor.canvas)
                .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
            if notesDraft != (contact.notes ?? "") {
                Button("Enregistrer") { onSaveNotes(notesDraft) }
                    .buttonStyle(C2VTextButtonStyle())
            }
        }
    }

    private var actions: some View {
        VStack(spacing: 10) {
            ShareLink(
                item: VcfShareFile(card: contact.toCard()),
                preview: SharePreview(VcfShare.fileName(contact.toCard()))
            ) {
                Text("Exporter VCF")
            }
            .buttonStyle(C2VOutlineButtonStyle())
            Button("Créer le contact") { showSystemContact = true }
                .buttonStyle(C2VOutlineButtonStyle())
            Button("Supprimer") { confirmDelete = true }
                .buttonStyle(C2VOutlineButtonStyle())
        }
    }

    @ViewBuilder
    private func fieldBlock(_ label: String, _ value: String?) -> some View {
        if let value = value?.nilIfBlank {
            VStack(alignment: .leading, spacing: 2) {
                Text(label)
                    .font(C2VFont.labelMedium)
                    .foregroundColor(C2VColor.texteFaible)
                Text(value)
                    .font(C2VFont.bodyLarge)
                    .foregroundColor(C2VColor.encre)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }

    @ViewBuilder
    private func clickableFieldBlock(
        _ label: String,
        values: [String],
        onTap: @escaping (String) -> Void
    ) -> some View {
        if !values.isEmpty {
            VStack(alignment: .leading, spacing: 2) {
                Text(label)
                    .font(C2VFont.labelMedium)
                    .foregroundColor(C2VColor.texteFaible)
                ForEach(values, id: \.self) { value in
                    Button {
                        onTap(value)
                    } label: {
                        Text(value)
                            .font(C2VFont.bodyLarge)
                            .foregroundColor(C2VColor.link)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.vertical, 4)
                    }
                    .buttonStyle(.plain)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }

    // MARK: - Notes vocales

    private var notesVocalesSection: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack {
                Text("Notes vocales")
                    .font(C2VFont.labelMedium)
                    .foregroundColor(C2VColor.texteFaible)
                Spacer()
                Button {
                    afficherEnregistrement = true
                } label: {
                    Label("Nouvelle note vocale", systemImage: "mic.badge.plus")
                        .font(C2VFont.labelSmall)
                        .foregroundColor(C2VColor.ink)
                }
                .buttonStyle(.plain)
            }
            if let erreur = relanceErreur {
                Text(erreur)
                    .font(C2VFont.bodySmall)
                    .foregroundColor(C2VColor.brandError)
            }
            if interactionsNoteVocale.isEmpty {
                Text("Aucune note vocale")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
            } else {
                ForEach(interactionsNoteVocale, id: \.localId) { interaction in
                    NoteVocaleRow(
                        sujet: interaction.sujet,
                        description: interaction.description,
                        audioPath: interaction.audioPath,
                        transcriptionStatut: interaction.transcriptionStatut,
                        transcriptionErreur: interaction.transcriptionErreur,
                        onRelancer: {
                            Task { await relancerTranscription(localId: interaction.localId) }
                        },
                        onSupprimer: {
                            Task { await supprimerInteraction(interaction) }
                        }
                    )
                    C2VDivider()
                }
            }
        }
    }

    private func chargerInteractions() async {
        guard let serverId = contact.serverId else { return }
        let all = (try? await Card2vcfDatabase.shared.interactionDao
            .fetchByContactServerId(serverId)) ?? []
        interactionsNoteVocale = all
            .filter { $0.type == "note_vocale" }
            .sorted { ($0.updatedAt ?? $0.createdAt) > ($1.updatedAt ?? $1.createdAt) }
    }

    private func relancerTranscription(localId: Int64) async {
        relanceErreur = nil
        guard let baseUrl = SyncCredentialsStore().baseUrl,
              let apiKey = SyncCredentialsStore().apiKey else {
            relanceErreur = "Relance impossible : serveur non configuré."
            return
        }
        let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
        let engine = SyncEngine(api: api, db: Card2vcfDatabase.shared)
        if let erreur = try? await engine.relancerTranscriptionInteraction(localId: localId) {
            relanceErreur = erreur
        }
        await chargerInteractions()
    }

    private func supprimerInteraction(_ interaction: InteractionEntity) async {
        let db = Card2vcfDatabase.shared
        // 1. Supprimer le fichier audio local s'il existe.
        if let path = interaction.audioPath {
            try? FileManager.default.removeItem(atPath: path)
        }
        // 2. Op de synchronisation uniquement si l'entité a déjà un serverId.
        if let serverId = interaction.serverId {
            var op = SyncOpEntity(entityType: "interaction", op: "delete")
            op.serverId = serverId
            op.createdAt = ContactRepository.currentMillis()
            _ = try? await db.syncOpDao.insert(op)
        }
        // 3. Suppression locale.
        try? await db.interactionDao.deleteByLocalId(interaction.localId)
        await chargerInteractions()
    }

    // MARK: - Sauvegarde note vocale (statique pour capture sûre)

    static func sauvegarderInteractionVocale(
        contactServerId: String?,
        sujet: String,
        description: String,
        audioPath: String,
        demandeTranscription: Bool
    ) async -> Bool {
        guard let contactServerId else { return false }
        let db = Card2vcfDatabase.shared
        let now = ContactRepository.currentMillis()

        // Insertion
        var entity = InteractionEntity()
        entity.contactServerId = contactServerId
        entity.type = "note_vocale"
        entity.sujet = sujet
        entity.description = description
        entity.creePar = SyncUserContext.userName
        entity.createdAt = now
        if demandeTranscription { entity.transcriptionStatut = "en_attente" }
        let localId = (try? await db.interactionDao.upsert(entity)) ?? 0

        // Déplacement vers le store stable
        if let store = try? AudioNoteStore.defaultStore(), localId > 0 {
            let stablePath = store.pathForInteraction(localId: localId)
            if FileManager.default.fileExists(atPath: audioPath),
               (try? FileManager.default.moveItem(atPath: audioPath, toPath: stablePath)) != nil {
                var withPath = entity
                withPath.localId = localId
                withPath.audioPath = stablePath
                _ = try? await db.interactionDao.upsert(withPath)
            }
        }

        // Op de synchronisation
        var op = SyncOpEntity(entityType: "interaction", op: "create")
        op.payloadJson = encodeInteractionPayload(
            sujet: sujet, description: description,
            demandeTranscription: demandeTranscription
        )
        op.serverId = contactServerId
        op.localId = localId
        op.createdAt = now
        _ = try? await db.syncOpDao.insert(op)

        // Push immédiat en mode serveur
        if demandeTranscription,
           let baseUrl = SyncCredentialsStore().baseUrl,
           let apiKey = SyncCredentialsStore().apiKey {
            let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
            let engine = SyncEngine(api: api, db: db)
            let result = try? await engine.pousserEnAttente()
            return result?.success == true
        }
        return false
    }

    private static func encodeInteractionPayload(
        sujet: String,
        description: String,
        demandeTranscription: Bool
    ) -> String {
        let obj: [String: Any] = [
            "demande_transcription": demandeTranscription,
            "description": description,
            "sujet": sujet,
            "type_interaction": "note_vocale"
        ]
        guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]),
              let text = String(data: data, encoding: .utf8) else { return "{}" }
        return text
    }
}