CarnetViewModel.swift 91 lignes · 3111 octets
import Combine
import Foundation

// Port of ui/carnet/CarnetViewModel.kt. Kotlin observed Room flows; on iOS the
// DAOs are fetch-based, so the view model refreshes on demand and on the
// database-change notification posted by the persistence layer.

@MainActor
final class CarnetViewModel: ObservableObject {
    @Published private(set) var sort: ContactSort = .lastName
    @Published private(set) var query: String = ""
    @Published private(set) var sections: [AlphabetIndex.Section] = []
    @Published private(set) var duplicateCountById: [Int64: Int] = [:]
    /// Sync badge per contact id (blue = queued, red = failed).
    @Published private(set) var badges: [Int64: ContactSyncBadge] = [:]

    let repository: ContactRepository
    private let syncOpDao: SyncOpDao?

    private var cancellables: Set<AnyCancellable> = []
    private var refreshTask: Task<Void, Never>?

    init(
        repository: ContactRepository = ContactRepository(
            dao: Card2vcfDatabase.shared.crmContactDao,
            images: ContactImageStore()
        ),
        syncOpDao: SyncOpDao? = Card2vcfDatabase.shared.syncOpDao
    ) {
        self.repository = repository
        self.syncOpDao = syncOpDao
        NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in self?.scheduleRefresh() }
            .store(in: &cancellables)
    }

    var isEmpty: Bool {
        sections.isEmpty || sections.allSatisfy { $0.contacts.isEmpty }
    }

    var presentLetters: Set<Character> {
        Set(sections.map { $0.letter })
    }

    func setQuery(_ value: String) {
        query = value
        scheduleRefresh()
    }

    func setSort(_ value: ContactSort) {
        sort = value
        scheduleRefresh()
    }

    func scheduleRefresh() {
        refreshTask?.cancel()
        refreshTask = Task { await refresh() }
    }

    func refresh() async {
        do {
            let all = try await repository.fetchAll()
            let list: [CrmContactEntity]
            if query.isBlank {
                list = all
            } else {
                list = try await repository.search(query)
            }
            let exclusions = (try? await repository.getExclusions()) ?? []
            let clusters = DuplicateDetector.clusters(contacts: all, exclusions: exclusions)
            var ops: [SyncOpEntity] = []
            if let syncOpDao {
                ops = (try? await syncOpDao.listAll()) ?? []
            }
            guard !Task.isCancelled else { return }
            sections = AlphabetIndex.group(contacts: list, sort: sort)
            duplicateCountById = Dictionary(
                uniqueKeysWithValues: all.map {
                    ($0.id, DuplicateDetector.duplicateCount($0.id, clusters: clusters))
                }
            )
            badges = ContactSyncBadges.compute(contacts: all, ops: ops)
        } catch {
            guard !Task.isCancelled else { return }
            sections = []
            duplicateCountById = [:]
            badges = [:]
        }
    }
}