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