// // 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 Compound import SwiftUI struct HealthChatProfileEditScreen: View { @Bindable var context: HealthChatProfileEditScreenViewModel.Context var body: some View { content .navigationTitle(L10n.screenEditProfileTitle) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(context.viewState.canSave) .toolbar { toolbar } .alert(item: $context.alertInfo) } // MARK: - Private @ViewBuilder private var content: some View { if context.viewState.loadFailed { failureView } else { form } } private var form: some View { Form { Section { avatar } Section { textFieldRow(HealthChatL10n.commonAcademicTitle, text: $context.title) textFieldRow(requiredLabel(HealthChatL10n.commonFirstName), text: $context.firstName) textFieldRow(requiredLabel(HealthChatL10n.commonLastName), text: $context.lastName) } header: { Text(HealthChatL10n.screenProfileEditSectionPersonal) .compoundListSectionHeader() } Section { textFieldRow(requiredLabel(HealthChatL10n.commonSpecialty), text: $context.specialty) textFieldRow(requiredLabel(HealthChatL10n.commonPracticeName), text: $context.practiceName) textFieldRow(requiredLabel(HealthChatL10n.commonCity), text: $context.businessCity) textFieldRow(HealthChatL10n.commonFurtherInfo, text: $context.furtherInfo, axis: .vertical) } header: { Text(HealthChatL10n.screenProfileEditSectionProfessional) .compoundListSectionHeader() } Section { textFieldRow(HealthChatL10n.commonBusinessEmail, text: $context.businessEmail) textFieldRow(HealthChatL10n.commonBusinessTel, text: $context.businessTel) } header: { Text(HealthChatL10n.screenProfileEditSectionContact) .compoundListSectionHeader() } Section { textFieldRow(HealthChatL10n.commonStreet, text: $context.street) textFieldRow(HealthChatL10n.commonHouseNumber, text: $context.houseNumber) textFieldRow(HealthChatL10n.commonPostalCode, text: $context.postalCode) textFieldRow(HealthChatL10n.commonWebsite, text: $context.website) } header: { Text(HealthChatL10n.screenProfileEditSectionAddress) .compoundListSectionHeader() } } .compoundList() .scrollDismissesKeyboard(.immediately) .disabled(context.viewState.initialProfile == nil) } private var failureView: some View { VStack(spacing: 16) { BigIcon(icon: \.error) Text(L10n.commonSomethingWentWrong) .font(.compound.bodyLG) .foregroundColor(.compound.textSecondary) .multilineTextAlignment(.center) Button(L10n.actionRetry) { context.send(viewAction: .retry) } .buttonStyle(.compound(.secondary, size: .medium)) .fixedSize() } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(16) } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { if context.viewState.canSave { Button(L10n.actionCancel) { context.send(viewAction: .cancel) } } } ToolbarItem(placement: .confirmationAction) { Button(L10n.actionSave) { context.send(viewAction: .save) } .disabled(!context.viewState.canSave) } } private var avatar: some View { Button { context.send(viewAction: .presentMediaSource) } label: { OverridableAvatarImage(overrideURL: context.viewState.localMedia?.thumbnailURL, url: context.viewState.selectedAvatarURL, name: context.viewState.initialProfile?.displayName, contentID: context.viewState.initialProfile?.userID ?? "", shape: .circle, avatarSize: .user(on: .editUserDetails), mediaProvider: context.mediaProvider) .confirmationDialog("", isPresented: $context.showMediaSheet) { mediaActionSheet } } .buttonStyle(EditAvatarButtonStyle()) .disabled(!context.viewState.canEditAvatar) .frame(maxWidth: .infinity, alignment: .center) .listRowBackground(Color.clear) } private func textFieldRow(_ label: String, text: Binding, axis: Axis = .horizontal) -> some View { ListRow(label: .plain(title: label), kind: .textField(text: text, axis: axis)) } private func requiredLabel(_ label: String) -> String { "\(label) *" } @ViewBuilder private var mediaActionSheet: some View { Button { context.send(viewAction: .displayCameraPicker) } label: { Text(L10n.actionTakePhoto) } Button { context.send(viewAction: .displayMediaPicker) } label: { Text(L10n.actionChoosePhoto) } Button { context.send(viewAction: .displayFilePicker) } label: { Text(L10n.actionChooseFile) } if context.viewState.showDeleteImageAction { Button(role: .destructive) { context.send(viewAction: .removeImage) } label: { Text(L10n.actionRemove) } } } } // MARK: - Previews struct HealthChatProfileEditScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = makeViewModel() static var previews: some View { ElementNavigationStack { HealthChatProfileEditScreen(context: viewModel.context) } .snapshotPreferences(expect: viewModel.context.observe(\.viewState.initialProfile).map { $0 != nil }) .previewDisplayName("Profile") } static func makeViewModel() -> HealthChatProfileEditScreenViewModel { let apiService = HealthChatAPIServiceMock() let member = try? JSONDecoder().decode(HealthChatMember.self, from: Data(""" { "userId": "@alice:healthchat.ch", "displayName": "Dr. Alice Alpha", "title": "Dr. med.", "firstName": "Alice", "lastName": "Alpha", "specialty": "Physiotherapie", "practiceName": "Praxis Alpha", "businessCity": "Zürich", "businessEmail": "praxis@example.ch", "businessTel": "+41 44 123 45 67", "street": "Friedaustrasse", "houseNumber": "17", "postalCode": "8003", "website": "https://example.ch" } """.utf8)) if let member { apiService.ownProfileReturnValue = .success(member) } return HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))), apiService: apiService, mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: .mock()), userIndicatorController: UserIndicatorControllerMock()) } }