SyncBanner.swift 108 lignes · 3881 octets
import SwiftUI

// Port of ui/sync/SyncBanner.kt.

/// Stacked hairline banners: A) summary of the last sync (`summary`, with the
/// verbatim server message when ops were refused); B) changes on the server (or
/// error); C) local calendar ahead (`localAhead` > 0, events/entities not pushed
/// yet). Hidden when there is nothing to report.
struct SyncBanner: View {
    let pendingChanges: Int?
    let localAhead: Int
    let syncing: Bool
    let error: String?
    let onSyncClick: () -> Void
    var summary: SyncSummary? = nil

    var body: some View {
        let summaryLabel: String? = summary.map { s in
            if s.failures > 0 {
                let message = s.firstFailureMessage ?? "refus du serveur sans détail"
                return "Synchro : \(s.pushed) envoyé(s) · \(s.received) reçu(s) · \(s.failures) échec(s) : \(message)"
            }
            return "Synchro : \(s.pushed) envoyé(s) · \(s.received) reçu(s)"
        }
        let remoteLabel: String? = {
            if let error { return error }
            if let pendingChanges, pendingChanges > 0 {
                return "\(pendingChanges) changement(s) sur le serveur"
            }
            return nil
        }()
        if summaryLabel != nil || remoteLabel != nil || localAhead > 0 {
            VStack(spacing: 0) {
                if let summaryLabel {
                    SyncBannerRow(label: summaryLabel, syncing: syncing, onSyncClick: onSyncClick)
                }
                if let remoteLabel {
                    SyncBannerRow(label: remoteLabel, syncing: syncing, onSyncClick: onSyncClick)
                }
                if localAhead > 0 {
                    SyncBannerRow(
                        label: "Calendrier local en avance sur le serveur",
                        syncing: syncing,
                        onSyncClick: onSyncClick
                    )
                }
            }
        }
    }
}

private struct SyncBannerRow: View {
    let label: String
    let syncing: Bool
    let onSyncClick: () -> Void

    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Text(label)
                    .font(C2VFont.bodyLarge)
                    .foregroundColor(C2VColor.ink)
                    .frame(maxWidth: .infinity, alignment: .leading)
                Button(syncing ? "Synchronisation…" : "Synchroniser", action: onSyncClick)
                    .buttonStyle(C2VTextButtonStyle())
                    .disabled(syncing)
            }
            .padding(.vertical, 8)
            C2VDivider()
        }
    }
}

/// « Annuler ma réservation ? » dialog shown after a `SyncChromeViewModel.syncNow`
/// hit a conflict (409). Kotlin `SyncConflictDialog`, applied as a modifier.
private struct SyncConflictDialogModifier: ViewModifier {
    let conflict: AgendaConflict?
    let onConfirm: () -> Void
    let onDismiss: () -> Void

    func body(content: Content) -> some View {
        content.overlay {
            if conflict != nil {
                C2VDialog(
                    title: "Annuler ma réservation ?",
                    confirmLabel: "Oui, annuler",
                    dismissLabel: "Non",
                    onConfirm: onConfirm,
                    onDismiss: onDismiss
                ) {
                    Text("Cette réservation est en conflit avec une autre sur le serveur. Voulez-vous l'annuler ?")
                        .font(C2VFont.bodyLarge)
                        .foregroundColor(C2VColor.ink)
                }
            }
        }
    }
}

extension View {
    func syncConflictDialog(
        conflict: AgendaConflict?,
        onConfirm: @escaping () -> Void,
        onDismiss: @escaping () -> Void
    ) -> some View {
        modifier(SyncConflictDialogModifier(conflict: conflict, onConfirm: onConfirm, onDismiss: onDismiss))
    }
}