SyncChromeViewModel.swift 157 lignes · 6439 octets
import Foundation

// Port of ui/sync/SyncChromeViewModel.kt.

/// Summary of the last sync, rendered by `SyncBanner`. `firstFailureMessage` is
/// the verbatim server message of the first refusal, `nil` when the server gave none.
struct SyncSummary: Equatable {
    var pushed: Int
    var received: Int
    var failures: Int
    var firstFailureMessage: String? = nil
}

/// Sync banner state: server-side changes + local calendar ahead on screen open
/// (`refreshStatus`, no auto-pull), manual sync on demand (`syncNow`: push then pull).
/// After `syncNow`, a conflicting reservation (409) triggers the « Annuler ma
/// réservation ? » dialog (`conflictPending`) — one decision at a time when
/// several conflicts are queued.
@MainActor
final class SyncChromeViewModel: ObservableObject {
    @Published private(set) var configured: Bool
    @Published private(set) var pendingRemoteChanges: Int?
    @Published private(set) var localCalendarAhead = 0
    @Published private(set) var syncing = false
    @Published private(set) var error: String?
    /// Summary of the last successful sync; cleared at the start of each `syncNow`.
    @Published private(set) var syncSummary: SyncSummary?
    @Published private(set) var conflictPending: AgendaConflict?

    private var pendingConflicts: [AgendaConflict] = []

    private let credentialsStore: SyncCredentialsStore
    private let calendarBindingsStore: CalendarBindingsStore
    private let calendarBridge: CalendarBridge?
    private let engineFactory: () -> SyncEngine?

    init(
        credentialsStore: SyncCredentialsStore,
        calendarBindingsStore: CalendarBindingsStore,
        calendarBridge: CalendarBridge?,
        engineFactory: @escaping () -> SyncEngine?
    ) {
        self.credentialsStore = credentialsStore
        self.calendarBindingsStore = calendarBindingsStore
        self.calendarBridge = calendarBridge
        self.engineFactory = engineFactory
        self.configured = credentialsStore.isConfigured()
    }

    /// Production wiring (Kotlin `Card2vcfNavHost` remember block): engine built
    /// per call from the stored credentials, `nil` while not configured.
    convenience init() {
        let credentialsStore = SyncCredentialsStore()
        self.init(
            credentialsStore: credentialsStore,
            calendarBindingsStore: CalendarBindingsStore(),
            calendarBridge: EventKitCalendarBridge(),
            engineFactory: {
                guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else {
                    return nil
                }
                return SyncEngine(
                    api: AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey),
                    db: Card2vcfDatabase.shared,
                    imageStore: ContactImageStore()
                )
            }
        )
    }

    /// Called on screen open: `checkStatus` (no pull) + local-ahead count.
    func refreshStatus() {
        configured = credentialsStore.isConfigured()
        guard let engine = engineFactory() else { return }
        Task {
            let bindings = calendarBindingsStore.list()
            let ressourcesQuery = AgendaSyncCoordinator.ressourcesQuery(bindings)
            do {
                let result = try await engine.checkStatus(ressourcesQuery: ressourcesQuery)
                pendingRemoteChanges = result.pendingRemoteChanges
                error = result.error
            } catch {
                self.error = error.localizedDescription
            }
            await refreshLocalAhead(engine: engine, bindings: bindings)
        }
    }

    /// Manual sync: push queued ops then pull, refresh the banner counters after.
    func syncNow() {
        guard let engine = engineFactory() else { return }
        Task {
            syncing = true
            error = nil
            syncSummary = nil
            let bindings = calendarBindingsStore.list()
            do {
                let result = try await engine.syncNow(bindings: bindings, bridge: calendarBridge)
                if result.success {
                    setPendingConflicts(result.conflicts)
                    syncSummary = SyncSummary(
                        pushed: result.pushed,
                        received: result.received,
                        failures: result.pushFailures.count,
                        firstFailureMessage: result.pushFailures.first?.message?.nilIfBlank
                    )
                    let status = try await engine.checkStatus(
                        ressourcesQuery: AgendaSyncCoordinator.ressourcesQuery(bindings)
                    )
                    pendingRemoteChanges = status.pendingRemoteChanges
                    error = status.error
                } else {
                    error = result.error
                }
            } catch {
                self.error = error.localizedDescription
            }
            await refreshLocalAhead(engine: engine, bindings: bindings)
            syncing = false
        }
    }

    /// Conflict dialog, « Oui » : abandons the local reservation (entity + SyncOp +
    /// calendar event), then moves to the next conflict.
    func confirmConflictCancellation() {
        guard let conflict = conflictPending, let engine = engineFactory() else { return }
        Task {
            try? await engine.abandonLocalReservation(localId: conflict.localReservationId, bridge: calendarBridge)
            advanceConflictQueue()
            await refreshLocalAhead(engine: engine, bindings: calendarBindingsStore.list())
        }
    }

    /// Conflict dialog, « Non » : leaves the reservation untouched (stays
    /// `conflictPending` server-side, arbitration happens on the web server).
    func dismissConflict() {
        advanceConflictQueue()
    }

    private func refreshLocalAhead(engine: SyncEngine, bindings: [CalendarBinding]) async {
        if let count = try? await engine.localAheadCount(bindings: bindings, bridge: calendarBridge) {
            localCalendarAhead = count
        }
    }

    private func setPendingConflicts(_ conflicts: [AgendaConflict]) {
        pendingConflicts = conflicts
        conflictPending = pendingConflicts.first
    }

    private func advanceConflictQueue() {
        if !pendingConflicts.isEmpty {
            pendingConflicts.removeFirst()
        }
        conflictPending = pendingConflicts.first
    }
}