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