SettingsScreen.swift 356 lignes · 14052 octets
import EventKit
import SwiftUI

// Port of ui/settings/SettingsScreen.kt — login form (URL + identifiants,
// « Obtenir la clé » then password retype to decrypt) and, once connected,
// the Calendriers section (permission, « Mes RDV », resources catalogue).

/// EventKit counterpart of Android's `READ_CALENDAR`/`WRITE_CALENDAR` runtime check.
enum CalendarPermission {
    static var granted: Bool {
        let status = EKEventStore.authorizationStatus(for: .event)
        if #available(iOS 17.0, *) {
            return status == .fullAccess
        }
        return status == .authorized
    }

    static func request() async -> Bool {
        (try? await EventKitCalendarBridge().requestFullAccess()) ?? false
    }
}

struct SettingsScreen: View {
    @ObservedObject var viewModel: SettingsViewModel
    let onBack: () -> Void

    @State private var hasCalendarPermission = CalendarPermission.granted
    @State private var afficherAPropos = false

    var body: some View {
        VStack(spacing: 0) {
            ScreenTopBar(title: "Paramètres", onBack: onBack)
            switch viewModel.state {
            case .loggedIn(let state):
                loggedInContent(state)
            case .loggedOut(let state):
                loggedOutContent(state)
            }
            C2VDivider()
            Button("À propos") { afficherAPropos = true }
                .buttonStyle(C2VTextButtonStyle(color: C2VColor.texteFaible))
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, 10)
                .padding(.vertical, 6)
        }
        .frame(maxHeight: .infinity, alignment: .top)
        .background(C2VColor.fond)
        .toolbar(.hidden, for: .navigationBar)
        .onAppear {
            // Kotlin LaunchedEffect(hasCalendarPermission).
            hasCalendarPermission = CalendarPermission.granted
            viewModel.onCalendarPermissionChanged(granted: hasCalendarPermission)
        }
        .overlay { dialogs }
        .sheet(isPresented: $afficherAPropos) {
            AProposScreen(onBack: { afficherAPropos = false })
        }
    }

    // ---- Logged in ----

    private func loggedInContent(_ state: SettingsUiState.LoggedIn) -> some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 10) {
                Text("Connecté comme \(state.userName)")
                    .font(C2VFont.bodyLarge)
                    .foregroundColor(C2VColor.ink)
                Text(state.baseUrl)
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
                Text("Clé API : •••• (configurée)")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
                // Destructive action: error color from the charte.
                Button("Déconnecter") { viewModel.disconnect() }
                    .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError))

                C2VDivider()

                calendarSection(state)

                C2VDivider()

                transcriptionSection(state)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(18)
        }
    }

    @ViewBuilder
    private func calendarSection(_ state: SettingsUiState.LoggedIn) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Calendriers")
                .font(C2VFont.labelMedium)
                .foregroundColor(C2VColor.ink)

            if !hasCalendarPermission {
                Text("Autorisez l'accès à l'agenda pour lier vos rendez-vous et vos ressources.")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
                Button("Autoriser l'agenda") {
                    Task {
                        hasCalendarPermission = await CalendarPermission.request()
                        viewModel.onCalendarPermissionChanged(granted: hasCalendarPermission)
                    }
                }
                .buttonStyle(C2VOutlineButtonStyle())
            } else {
                ToggleRow(label: "Mes RDV", checked: state.mesRdvLinked, enabled: true) { enabled in
                    viewModel.setMesRdvEnabled(enabled)
                }

                if state.catalogueLoading {
                    Text("Chargement des ressources…")
                        .font(C2VFont.bodyMedium)
                        .foregroundColor(C2VColor.texteFaible)
                } else if let catalogueError = state.catalogueError {
                    Text(catalogueError)
                        .font(C2VFont.bodyMedium)
                        .foregroundColor(C2VColor.brandError)
                } else {
                    ressourceGroup(
                        title: "Salles",
                        items: state.ressources.filter { $0.kind == SettingsViewModel.KIND_SALLE }
                    )
                    ressourceGroup(
                        title: "Matériel",
                        items: state.ressources.filter { $0.kind == SettingsViewModel.KIND_MATERIEL }
                    )
                    ressourceGroup(
                        title: "Véhicules",
                        items: state.ressources.filter { $0.kind == SettingsViewModel.KIND_VEHICULE }
                    )
                }
            }
        }
    }

    @ViewBuilder
    private func ressourceGroup(title: String, items: [RessourceToggleState]) -> some View {
        if !items.isEmpty {
            VStack(alignment: .leading, spacing: 2) {
                Text(title)
                    .font(C2VFont.labelSmall)
                    .foregroundColor(C2VColor.texteFaible)
                ForEach(items, id: \.serverResourceId) { item in
                    ToggleRow(
                        label: item.actif ? item.nom : "\(item.nom) — inactive",
                        checked: item.linked,
                        enabled: item.actif
                    ) { enabled in
                        viewModel.setRessourceEnabled(
                            kind: item.kind,
                            serverResourceId: item.serverResourceId,
                            enabled: enabled
                        )
                    }
                }
            }
        }
    }

    @ViewBuilder
    private func transcriptionSection(_ state: SettingsUiState.LoggedIn) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Transcription des notes vocales")
                .font(C2VFont.labelMedium)
                .foregroundColor(C2VColor.ink)

            if !state.speechRecognitionDisponible {
                Text("La reconnaissance vocale n'est pas disponible sur cet appareil. La transcription s'effectue sur le serveur.")
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.texteFaible)
            } else {
                Picker(
                    "Mode",
                    selection: Binding(
                        get: { state.modeTranscription },
                        set: { viewModel.setModeTranscription($0) }
                    )
                ) {
                    Text("Sur l'appareil").tag(ModeTranscription.appareil)
                    Text("Sur le serveur").tag(ModeTranscription.serveur)
                }
                .pickerStyle(.segmented)

                Text(state.modeTranscription == .appareil
                     ? "Transcription locale, sans connexion réseau."
                     : "L'audio est envoyé au serveur pour transcription.")
                    .font(C2VFont.bodySmall)
                    .foregroundColor(C2VColor.texteFaible)
            }
        }
    }

    // ---- Logged out ----

    private func loggedOutContent(_ state: SettingsUiState.LoggedOut) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            C2VTextField(
                label: "",
                placeholder: "URL du serveur (https://…)",
                text: Binding(get: { state.baseUrl }, set: { viewModel.setBaseUrl($0) })
            )
            .keyboardType(.URL)
            .textInputAutocapitalization(.never)
            .autocorrectionDisabled()
            C2VTextField(
                label: "",
                placeholder: "Nom d'utilisateur",
                text: Binding(get: { state.userName }, set: { viewModel.setUserName($0) })
            )
            .textInputAutocapitalization(.never)
            .autocorrectionDisabled()
            C2VSecureField(
                placeholder: "Mot de passe",
                text: Binding(get: { state.password }, set: { viewModel.setPassword($0) })
            )
            ToggleRow(label: "Autoriser HTTP (clair)", checked: state.allowCleartextHttp, enabled: true) { value in
                viewModel.setAllowCleartextHttp(value)
            }
            Text("Uniquement pour le développement, le réseau interne ou une démo. HTTPS reste recommandé.")
                .font(C2VFont.bodySmall)
                .foregroundColor(C2VColor.texteFaible)
            if let error = state.error {
                Text(error)
                    .font(C2VFont.bodyMedium)
                    .foregroundColor(C2VColor.brandError)
            }
            Button("Obtenir la clé") { viewModel.obtainKey() }
                .buttonStyle(C2VPrimaryButtonStyle())
                .disabled(state.loading)
        }
        .padding(18)
    }

    // ---- Dialogs ----

    @ViewBuilder
    private var dialogs: some View {
        switch viewModel.state {
        case .loggedOut(let state):
            if state.pendingAuth != nil {
                retypeDialog(state)
            }
        case .loggedIn(let state):
            if let pending = state.pendingRemoval {
                removeBindingDialog(pending)
            } else if let pending = state.pendingICloudActivation {
                iCloudActivationDialog(pending)
            }
        }
    }

    /// iOS only: iCloud Calendar hides the local source — the user chooses
    /// between creating the calendar in their iCloud account or giving up.
    private func iCloudActivationDialog(_ pending: PendingICloudActivation) -> some View {
        C2VDialog(
            title: "iCloud Agenda est actif",
            confirmLabel: "Utiliser iCloud",
            dismissLabel: "Annuler",
            onConfirm: { viewModel.confirmICloudActivation() },
            onDismiss: { viewModel.cancelICloudActivation() }
        ) {
            Text(
                "iOS masque le stockage local quand iCloud Agenda est activé : "
                    + "« \(pending.displayName) » ne peut pas rester uniquement sur l'appareil. "
                    + "Créer le calendrier dans votre compte iCloud (synchronisé sur vos appareils) ? "
                    + "Pour un calendrier 100 % local, désactivez Calendrier dans Réglages → iCloud, puis réessayez."
            )
            .font(C2VFont.bodyLarge)
            .foregroundColor(C2VColor.ink)
        }
    }

    private func retypeDialog(_ state: SettingsUiState.LoggedOut) -> some View {
        C2VDialog(
            title: "Retapez le mot de passe pour déchiffrer",
            confirmLabel: "Confirmer",
            dismissLabel: "Annuler",
            onConfirm: { viewModel.confirmRetype() },
            onDismiss: { viewModel.cancelRetype() }
        ) {
            VStack(alignment: .leading, spacing: 8) {
                C2VSecureField(
                    placeholder: "Mot de passe",
                    text: Binding(get: { state.retypePassword }, set: { viewModel.setRetypePassword($0) })
                )
                if let retypeError = state.retypeError {
                    Text(retypeError)
                        .font(C2VFont.bodyMedium)
                        .foregroundColor(C2VColor.brandError)
                }
            }
        }
    }

    private func removeBindingDialog(_ pending: PendingCalendarRemoval) -> some View {
        C2VDialog(
            title: "Retirer ce calendrier ?",
            confirmLabel: "Retirer",
            dismissLabel: "Annuler",
            onConfirm: { viewModel.confirmRemoveBinding() },
            onDismiss: { viewModel.dismissRemoveBinding() }
        ) {
            Text("« \(pending.displayName) » et ses événements seront supprimés de l'appareil. Vos RDV et réservations restent sur le serveur PicLead.")
                .font(C2VFont.bodyLarge)
                .foregroundColor(C2VColor.ink)
        }
    }
}

/// Compose `Switch` row: label (greyed when disabled) + trailing ink-tinted toggle.
private struct ToggleRow: View {
    let label: String
    let checked: Bool
    let enabled: Bool
    let onCheckedChange: (Bool) -> Void

    var body: some View {
        HStack {
            Text(label)
                .font(C2VFont.bodyLarge)
                .foregroundColor(enabled ? C2VColor.ink : C2VColor.texteFaible)
                .frame(maxWidth: .infinity, alignment: .leading)
            Toggle("", isOn: Binding(get: { checked }, set: { onCheckedChange($0) }))
                .labelsHidden()
                .tint(C2VColor.ink)
                .disabled(!enabled)
        }
    }
}

/// Secure variant of `C2VTextField` (Compose `PasswordVisualTransformation`).
private struct C2VSecureField: View {
    let placeholder: String
    @Binding var text: String

    @FocusState private var focused: Bool

    var body: some View {
        SecureField(placeholder, text: $text)
            .textFieldStyle(.plain)
            .font(C2VFont.bodyLarge)
            .foregroundColor(C2VColor.ink)
            .tint(C2VColor.ink)
            .focused($focused)
            .padding(.horizontal, 12)
            .padding(.vertical, 10)
            .background(C2VColor.canvas)
            .overlay(
                Rectangle().stroke(focused ? C2VColor.ink : C2VColor.hairline, lineWidth: 1)
            )
    }
}