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