// // Copyright 2026 Novaloop AG // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Combine import SwiftUI typealias HealthChatProfileEditScreenViewModelType = StateStoreViewModelV2 class HealthChatProfileEditScreenViewModel: HealthChatProfileEditScreenViewModelType, HealthChatProfileEditScreenViewModelProtocol { private let clientProxy: ClientProxyProtocol private let apiService: HealthChatAPIServiceProtocol private let mediaUploadingPreprocessor: MediaUploadingPreprocessor private let userIndicatorController: UserIndicatorControllerProtocol private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } init(userSession: UserSessionProtocol, apiService: HealthChatAPIServiceProtocol, mediaUploadingPreprocessor: MediaUploadingPreprocessor, userIndicatorController: UserIndicatorControllerProtocol) { clientProxy = userSession.clientProxy self.apiService = apiService self.mediaUploadingPreprocessor = mediaUploadingPreprocessor self.userIndicatorController = userIndicatorController super.init(initialViewState: HealthChatProfileEditScreenViewState(), mediaProvider: userSession.mediaProvider) Task { await loadProfile() } } // MARK: - Public override func process(viewAction: HealthChatProfileEditScreenViewAction) { switch viewAction { case .cancel: showUnsavedChangesAlert() // The cancel button is only shown when there are unsaved changes. case .save: Task { await saveProfile() } case .retry: Task { await loadProfile() } case .presentMediaSource: state.bindings.showMediaSheet = true case .displayCameraPicker: actionsSubject.send(.displayCameraPicker) case .displayMediaPicker: actionsSubject.send(.displayMediaPicker) case .displayFilePicker: actionsSubject.send(.displayFilePicker) case .removeImage: state.localMedia = nil state.selectedAvatarURL = nil } } func didSelectMediaURL(url: URL) { Task { let userIndicatorID = UUID().uuidString defer { userIndicatorController.retractIndicatorWithId(userIndicatorID) } userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), title: L10n.commonLoading, persistent: true)) guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { MXLog.error("Failed to get max upload size") state.bindings.alertInfo = .init(id: .unknown) return } let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize) switch mediaResult { case .success(.image): state.localMedia = try? mediaResult.get() case .failure, .success: state.bindings.alertInfo = .init(id: .failedProcessingMedia) } } } // MARK: - Private private func loadProfile() async { state.loadFailed = false state.canEditAvatar = await clientProxy.capabilities.canChangeAvatar() switch await apiService.ownProfile() { case .success(let profile): state.selectedAvatarURL = profile.avatarURL state.bindings.title = profile.title ?? "" state.bindings.firstName = profile.firstName ?? "" state.bindings.lastName = profile.lastName ?? "" state.bindings.specialty = profile.specialty ?? "" state.bindings.practiceName = profile.practiceName ?? "" state.bindings.businessCity = profile.businessCity ?? "" state.bindings.businessEmail = profile.businessEmail ?? "" state.bindings.businessTel = profile.businessTel ?? "" state.bindings.furtherInfo = profile.furtherInfo ?? "" state.bindings.street = profile.street ?? "" state.bindings.houseNumber = profile.houseNumber ?? "" state.bindings.postalCode = profile.postalCode ?? "" state.bindings.website = profile.website ?? "" state.initialProfile = profile case .failure: state.loadFailed = true } } private func showUnsavedChangesAlert() { state.bindings.alertInfo = .init(id: .unsavedChanges, title: L10n.dialogUnsavedChangesTitle, message: L10n.dialogUnsavedChangesDescription, primaryButton: .init(title: L10n.actionSave) { Task { await self.saveProfile() } }, secondaryButton: .init(title: L10n.actionDiscard, role: .cancel) { self.actionsSubject.send(.dismiss) }) } private func saveProfile() async { let userIndicatorID = UUID().uuidString defer { userIndicatorController.retractIndicatorWithId(userIndicatorID) } userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), title: L10n.screenEditProfileUpdatingDetails, persistent: true)) do { if state.avatarDidChange { try await saveAvatar() } // The fields go second so the derived display name reflects the latest names. if let update = state.fieldsUpdate { try await apiService.updateOwnProfile(update).get() } actionsSubject.send(.dismiss) } catch { MXLog.error("Failed saving the HealthChat profile with error: \(error)") state.bindings.alertInfo = .init(id: .saveError, title: L10n.screenEditProfileErrorTitle, message: L10n.screenEditProfileError) } } private func saveAvatar() async throws { if let localMedia = state.localMedia { try await clientProxy.setUserAvatar(media: localMedia).get() } else if state.selectedAvatarURL == nil { try await clientProxy.removeUserAvatar().get() } } }