SettingsViewModel.swift
432 lignes · 17168 octets
import Foundation // Port of ui/settings/SettingsViewModel.kt. /// « Ressource » toggle (salle/matériel/véhicule) shown in Paramètres, derived from /// the server catalogue + local bindings. struct RessourceToggleState: Equatable { var kind: String var serverResourceId: String var nom: String var displayName: String var actif: Bool var linked: Bool } /// Calendar binding awaiting confirmation before removal (see `SettingsViewModel.confirmRemoveBinding`). struct PendingCalendarRemoval: Equatable { var kind: String var serverResourceId: String? var displayName: String } /// Activation awaiting the user's choice when iCloud hides the local calendar /// source: create the calendar in the account source (iCloud), or give up. struct PendingICloudActivation: Equatable { var kind: String var serverResourceId: String? var displayName: String } /// Paramètres screen state: logged in, or login form (+ pending retype). enum SettingsUiState { struct LoggedIn { var userName: String var baseUrl: String var calendarPermissionGranted = false var mesRdvLinked = false var ressources: [RessourceToggleState] = [] var catalogueLoading = false var catalogueError: String? = nil var pendingRemoval: PendingCalendarRemoval? = nil var pendingICloudActivation: PendingICloudActivation? = nil var modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription var speechRecognitionDisponible: Bool = PreferencesAudio.speechRecognitionDisponible } struct LoggedOut { var baseUrl = "" var userName = "" var password = "" /// When true, also accepts `http://` (dev / internal / demo). Default false = HTTPS only. var allowCleartextHttp = false var loading = false var error: String? = nil var pendingAuth: AuthCleResponse? = nil var retypePassword = "" var retypeError: String? = nil } case loggedIn(LoggedIn) case loggedOut(LoggedOut) } /// API-key login: `obtainKey` calls `/api/auth/cle`, `confirmRetype` decrypts and stores /// (key persisted, the password itself never is). Once connected, also drives the /// Calendriers section: permission, « Mes RDV » binding and active resources (server /// catalogue), with a confirmation dialog before removing a binding. @MainActor final class SettingsViewModel: ObservableObject { static let KIND_SALLE = "salle" static let KIND_MATERIEL = "materiel" static let KIND_VEHICULE = "vehicule" static let MES_RDV_DISPLAY_NAME = "PicLead — Mes RDV" /// Shown when the calendar deletion fails (Agenda permission revoked meanwhile). static let CALENDAR_DELETE_ERROR = "Calendrier non supprimé : autorisation Agenda manquante. La liaison a bien été retirée." @Published private(set) var state: SettingsUiState private let credentialsStore: SyncCredentialsStore private let calendarBindingsStore: CalendarBindingsStore private let calendarBridge: CalendarBridge private let apiFactory: (String) -> AilianceApi private let authenticatedApiFactory: (String, String) -> AilianceApi init( credentialsStore: SyncCredentialsStore = SyncCredentialsStore(), calendarBindingsStore: CalendarBindingsStore = CalendarBindingsStore(), calendarBridge: CalendarBridge = EventKitCalendarBridge(), apiFactory: @escaping (String) -> AilianceApi = { baseUrl in AilianceApiClient(baseUrl: baseUrl) }, authenticatedApiFactory: @escaping (String, String) -> AilianceApi = { baseUrl, apiKey in AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) } ) { self.credentialsStore = credentialsStore self.calendarBindingsStore = calendarBindingsStore self.calendarBridge = calendarBridge self.apiFactory = apiFactory self.authenticatedApiFactory = authenticatedApiFactory if credentialsStore.isConfigured(), let baseUrl = credentialsStore.baseUrl, let userName = credentialsStore.userName { state = .loggedIn(SettingsUiState.LoggedIn( userName: userName, baseUrl: baseUrl, mesRdvLinked: calendarBindingsStore.list().contains { $0.kind == AgendaSyncCoordinator.kindRdv } )) } else { state = .loggedOut(SettingsUiState.LoggedOut( baseUrl: credentialsStore.baseUrl ?? "", allowCleartextHttp: credentialsStore.allowCleartextHttp )) } } // ---- Login form ---- func setBaseUrl(_ value: String) { updateLoggedOut { $0.baseUrl = value; $0.error = nil } } func setUserName(_ value: String) { updateLoggedOut { $0.userName = value; $0.error = nil } } func setPassword(_ value: String) { updateLoggedOut { $0.password = value; $0.error = nil } } func setRetypePassword(_ value: String) { updateLoggedOut { $0.retypePassword = value; $0.retypeError = nil } } func setAllowCleartextHttp(_ value: Bool) { credentialsStore.allowCleartextHttp = value updateLoggedOut { $0.allowCleartextHttp = value; $0.error = nil } } func obtainKey() { guard case .loggedOut(let s) = state else { return } let baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl) let userName = s.userName.trimmingCharacters(in: .whitespacesAndNewlines) if baseUrl.isBlank || userName.isBlank || s.password.isBlank { updateLoggedOut { $0.error = "Renseignez l'URL, le nom d'utilisateur et le mot de passe" } return } if let err = ServerUrlPolicy.validate(baseUrl, allowCleartext: s.allowCleartextHttp) { updateLoggedOut { $0.error = err } return } Task { updateLoggedOut { $0.loading = true; $0.error = nil } let result = await apiFactory(baseUrl).authCle(nom: userName, motDePasse: s.password) guard case .loggedOut(var current) = state else { return } switch result { case .ok(let value): current.loading = false current.password = "" current.pendingAuth = value current.error = nil case .err(let code, let message): current.loading = false current.password = "" current.error = Self.errorMessageFor(code: code, message: message) } state = .loggedOut(current) } } func confirmRetype() { guard case .loggedOut(let s) = state, let auth = s.pendingAuth else { return } if s.retypePassword.isBlank { updateLoggedOut { $0.retypeError = "Mot de passe requis" } return } let retypePassword = s.retypePassword Task { // Argon2id KDF (19 MiB, t=2) is CPU-heavy — run it off the main actor. let apiKey: String? = await Task.detached(priority: .userInitiated) { try? ApiCleCrypto.decryptApiKey( password: retypePassword, saltB64: auth.sel, cipherB64: auth.cleChiffree ) }.value guard case .loggedOut(var current) = state else { return } guard let apiKey else { current.retypePassword = "" current.retypeError = "Mot de passe incorrect" state = .loggedOut(current) return } let baseUrl = ServerUrlPolicy.normalizeBaseUrl(current.baseUrl) credentialsStore.save(baseUrl: baseUrl, apiKey: apiKey, userName: auth.nom) state = .loggedIn(SettingsUiState.LoggedIn( userName: auth.nom, baseUrl: baseUrl, mesRdvLinked: calendarBindingsStore.list().contains { $0.kind == AgendaSyncCoordinator.kindRdv } )) } } func cancelRetype() { updateLoggedOut { $0.pendingAuth = nil; $0.retypePassword = ""; $0.retypeError = nil } } func disconnect() { credentialsStore.clear() state = .loggedOut(SettingsUiState.LoggedOut( allowCleartextHttp: credentialsStore.allowCleartextHttp )) } // ---- Calendriers ---- /// Called by the screen after the EventKit access request (Android runtime /// permission `READ_CALENDAR`/`WRITE_CALENDAR` equivalent). func onCalendarPermissionChanged(granted: Bool) { updateLoggedIn { $0.calendarPermissionGranted = granted } if granted { refreshRessourcesCatalogue() } } func setMesRdvEnabled(_ enabled: Bool) { guard case .loggedIn = state else { return } if enabled { requestActivation( kind: AgendaSyncCoordinator.kindRdv, serverResourceId: nil, displayName: Self.MES_RDV_DISPLAY_NAME ) } else { updateLoggedIn { $0.pendingRemoval = PendingCalendarRemoval( kind: AgendaSyncCoordinator.kindRdv, serverResourceId: nil, displayName: Self.MES_RDV_DISPLAY_NAME ) } } } func setRessourceEnabled(kind: String, serverResourceId: String, enabled: Bool) { guard case .loggedIn(let s) = state else { return } guard let item = s.ressources.first(where: { $0.kind == kind && $0.serverResourceId == serverResourceId }), item.actif else { return } if enabled { requestActivation(kind: kind, serverResourceId: serverResourceId, displayName: item.displayName) } else { updateLoggedIn { $0.pendingRemoval = PendingCalendarRemoval( kind: kind, serverResourceId: serverResourceId, displayName: item.displayName ) } } } /// Activates directly when the local source exists; otherwise defers to the /// « iCloud ou rien » user choice (`confirmICloudActivation` / `cancelICloudActivation`). private func requestActivation(kind: String, serverResourceId: String?, displayName: String) { guard calendarBridge.hasLocalSource() else { updateLoggedIn { $0.pendingICloudActivation = PendingICloudActivation( kind: kind, serverResourceId: serverResourceId, displayName: displayName ) } return } activateBinding(kind: kind, serverResourceId: serverResourceId, displayName: displayName) } /// « Utiliser iCloud » : the calendar is created in the account source. func confirmICloudActivation() { guard case .loggedIn(let s) = state, let pending = s.pendingICloudActivation else { return } updateLoggedIn { $0.pendingICloudActivation = nil } activateBinding( kind: pending.kind, serverResourceId: pending.serverResourceId, displayName: pending.displayName ) } /// « Annuler » : the toggle stays off, nothing is created. func cancelICloudActivation() { updateLoggedIn { $0.pendingICloudActivation = nil } } private func activateBinding(kind: String, serverResourceId: String?, displayName: String) { guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: displayName) else { return } calendarBindingsStore.upsert(CalendarBinding( kind: kind, serverResourceId: serverResourceId, displayName: displayName, androidCalendarId: calendarId )) updateLoggedIn { current in if kind == AgendaSyncCoordinator.kindRdv { current.mesRdvLinked = true } else if let serverResourceId { current.ressources = current.ressources.map { $0.withLinked(kind: kind, serverResourceId: serverResourceId, linked: true) } } } } /// Confirms the binding removal (« Retirer » in the confirmation dialog): deletes the /// local calendar from the device (`CalendarBridge.deleteCalendar`, events cascade) /// **then** removes the binding (`CalendarBindingsStore.remove`). Server data (RDV, /// réservations) is untouched. If the deletion fails (Agenda permission revoked /// meanwhile), the binding is removed anyway and the user is told via `catalogueError`. func confirmRemoveBinding() { guard case .loggedIn(let s) = state, let pending = s.pendingRemoval else { return } let calendarId = calendarBindingsStore.list() .first { $0.kind == pending.kind && $0.serverResourceId == pending.serverResourceId }? .androidCalendarId var deleted = true if let calendarId { do { try calendarBridge.deleteCalendar(calendarId: calendarId) } catch { deleted = false } } calendarBindingsStore.remove(kind: pending.kind, serverResourceId: pending.serverResourceId) updateLoggedIn { current in if pending.kind == AgendaSyncCoordinator.kindRdv { current.mesRdvLinked = false } else { current.ressources = current.ressources.map { $0.withLinked(kind: pending.kind, serverResourceId: pending.serverResourceId, linked: false) } } current.pendingRemoval = nil if !deleted { current.catalogueError = Self.CALENDAR_DELETE_ERROR } } } func dismissRemoveBinding() { updateLoggedIn { $0.pendingRemoval = nil } } func setModeTranscription(_ mode: ModeTranscription) { PreferencesAudio.modeTranscription = mode updateLoggedIn { $0.modeTranscription = mode } } private func refreshRessourcesCatalogue() { guard case .loggedIn = state else { return } guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else { return } updateLoggedIn { $0.catalogueLoading = true; $0.catalogueError = nil } Task { let result = await authenticatedApiFactory(baseUrl, apiKey).getRessourcesCatalogue() switch result { case .ok(let catalogue): let bindings = calendarBindingsStore.list() updateLoggedIn { $0.ressources = Self.buildRessourceToggles(catalogue: catalogue, bindings: bindings) $0.catalogueLoading = false } case .err(_, let message): updateLoggedIn { $0.catalogueLoading = false; $0.catalogueError = message } } } } static func buildRessourceToggles( catalogue: RessourcesCatalogueDto, bindings: [CalendarBinding] ) -> [RessourceToggleState] { func toggles(kind: String, items: [RessourceItemDto], label: String) -> [RessourceToggleState] { items.map { item in RessourceToggleState( kind: kind, serverResourceId: item.id, nom: item.nom, displayName: "PicLead — \(label) \(item.nom)", actif: item.actif, linked: bindings.contains { $0.kind == kind && $0.serverResourceId == item.id } ) } } return toggles(kind: KIND_SALLE, items: catalogue.salles, label: "Salle") + toggles(kind: KIND_MATERIEL, items: catalogue.materiels, label: "Matériel") + toggles(kind: KIND_VEHICULE, items: catalogue.vehicules, label: "Véhicule") } // ---- Helpers ---- private func updateLoggedOut(_ transform: (inout SettingsUiState.LoggedOut) -> Void) { guard case .loggedOut(var s) = state else { return } transform(&s) state = .loggedOut(s) } private func updateLoggedIn(_ transform: (inout SettingsUiState.LoggedIn) -> Void) { guard case .loggedIn(var s) = state else { return } transform(&s) state = .loggedIn(s) } static func errorMessageFor(code: Int, message: String) -> String { switch code { case 401: return "Identifiants invalides" case 404: return "Aucune clé API pour cet utilisateur" case -1: return "Erreur réseau : \(message)" default: return message } } } private extension RessourceToggleState { func withLinked(kind: String, serverResourceId: String?, linked: Bool) -> RessourceToggleState { guard self.kind == kind, self.serverResourceId == serverResourceId else { return self } var copy = self copy.linked = linked return copy } }
GitRust