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