// // 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 HealthChatMemberDirectoryScreen: View { @Bindable var context: HealthChatMemberDirectoryScreenViewModel.Context var body: some View { content .navigationTitle(HealthChatL10n.screenMemberDirectoryTitle) .navigationBarTitleDisplayMode(.inline) .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) .searchController(query: $context.searchQuery, placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder) .toolbar { toolbar } .alert(item: $context.alertInfo) } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .primaryAction) { Button { context.send(viewAction: .editProfile) } label: { CompoundIcon(\.edit) } .accessibilityLabel(L10n.screenEditProfileTitle) } if context.viewState.isApproved { ToolbarItem(placement: .primaryAction) { Button { context.send(viewAction: .invite) } label: { CompoundIcon(\.userAdd) } .accessibilityLabel(HealthChatL10n.screenInviteMemberTitle) } } } // MARK: - Private @ViewBuilder private var content: some View { switch context.viewState.content { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) case .pendingApproval(let message): statusView(icon: \.time, message: message) case .failure: failureView case .members(let members) where members.isEmpty: statusView(icon: \.search, message: L10n.commonNoResults) case .members(let members): memberList(members) } } private func memberList(_ members: [HealthChatMember]) -> some View { Form { if let completenessMessage = context.viewState.completenessMessage { Section { ListRow(label: .default(title: completenessMessage, icon: \.info), kind: .button { context.send(viewAction: .editProfile) }) } } Section { ForEach(members) { member in HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) { context.send(viewAction: .selectMember(member)) } startChatAction: { context.send(viewAction: .startChat(member)) } } } } .compoundList() } private var failureView: some View { VStack(spacing: 16) { statusContent(icon: \.error, message: L10n.commonSomethingWentWrong) Button(L10n.actionRetry) { context.send(viewAction: .retry) } .buttonStyle(.compound(.secondary, size: .medium)) .fixedSize() } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(16) } private func statusView(icon: KeyPath, message: String) -> some View { statusContent(icon: icon, message: message) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(16) } private func statusContent(icon: KeyPath, message: String) -> some View { VStack(spacing: 16) { BigIcon(icon: icon) Text(message) .font(.compound.bodyLG) .foregroundColor(.compound.textSecondary) .multilineTextAlignment(.center) } } } // MARK: - Previews struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePreview { static let loadedViewModel = makeViewModel(configuration: .members) static let incompleteProfileViewModel = makeViewModel(configuration: .incompleteProfile) static let pendingViewModel = makeViewModel(configuration: .pendingApproval) static let failureViewModel = makeViewModel(configuration: .failure) static var previews: some View { ElementNavigationStack { HealthChatMemberDirectoryScreen(context: loadedViewModel.context) } .snapshotPreferences(expect: loadedViewModel.context.observe(\.viewState.content).map { $0 != .loading }) .previewDisplayName("Members") ElementNavigationStack { HealthChatMemberDirectoryScreen(context: incompleteProfileViewModel.context) } .snapshotPreferences(expect: incompleteProfileViewModel.context.observe(\.viewState.completenessMessage).map { $0 != nil }) .previewDisplayName("Incomplete profile") ElementNavigationStack { HealthChatMemberDirectoryScreen(context: pendingViewModel.context) } .snapshotPreferences(expect: pendingViewModel.context.observe(\.viewState.content).map { $0 != .loading }) .previewDisplayName("Pending approval") ElementNavigationStack { HealthChatMemberDirectoryScreen(context: failureViewModel.context) } .snapshotPreferences(expect: failureViewModel.context.observe(\.viewState.content).map { $0 != .loading }) .previewDisplayName("Failure") } enum PreviewConfiguration { case members case incompleteProfile case pendingApproval case failure } static func makeViewModel(configuration: PreviewConfiguration) -> HealthChatMemberDirectoryScreenViewModel { let apiService = HealthChatAPIServiceMock() switch configuration { case .members: let members = try? JSONDecoder().decode([HealthChatMember].self, from: Data(""" [ { "userId": "@alice:healthchat.ch", "displayName": "Dr. Alice Alpha", "specialty": "Physiotherapie", "practiceName": "Praxis Alpha", "businessCity": "Zürich" }, { "userId": "@bob:healthchat.ch", "displayName": "Bob Beta", "specialty": "Ergotherapie", "businessCity": "Basel" }, { "userId": "@carol:healthchat.ch", "displayName": "Dr. Carol Gamma" } ] """.utf8)) apiService.membersSearchQueryReturnValue = .success(.init(users: members ?? [], isApproved: true, pendingMessage: nil)) apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(#"{ "isComplete": true }"#.utf8))) case .incompleteProfile: let member = try? JSONDecoder().decode(HealthChatMember.self, from: Data(""" { "userId": "@alice:healthchat.ch", "displayName": "Dr. Alice Alpha", "specialty": "Physiotherapie" } """.utf8)) apiService.membersSearchQueryReturnValue = .success(.init(users: member.map { [$0] } ?? [], isApproved: true, pendingMessage: nil)) apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(#"{ "isComplete": false, "message": "Profil unvollständig – Fehlende Angaben: Fachgebiet, Ort" }"#.utf8))) case .pendingApproval: apiService.membersSearchQueryReturnValue = .success(.init(users: [], isApproved: false, pendingMessage: "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde.")) case .failure: apiService.membersSearchQueryReturnValue = .failure(.networkFailure) } return HealthChatMemberDirectoryScreenViewModel(apiService: apiService, clientProxy: ClientProxyMock(.init()), mediaProvider: MediaProviderMock(.init()), userIndicatorController: UserIndicatorControllerMock()) } }