Files
element-x-ios/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift
T
Sedat Qaja b70e77f7c5 feat(healthchat): show default contacts while approval is pending
An unapproved account only gets the server's default contacts, which are
its way to reach support — list them instead of discarding them, and hide
the search field that cannot return anything.
2026-10-09 14:23:39 +02:00

269 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 {
// Searching is approved-only, and the search controller can't be applied conditionally.
if context.viewState.isApproved {
screen
.searchController(query: $context.searchQuery,
placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder)
} else {
screen
}
}
private var screen: some View {
content
.navigationTitle(HealthChatL10n.screenMemberDirectoryTitle)
.navigationBarTitleDisplayMode(.inline)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.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, let members):
pendingApprovalList(message: message, members: members)
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)
})
}
}
memberSection(members)
}
.compoundList()
}
/// The server allows an unapproved account the default contacts only, so they are listed
/// below the hint rather than withheld — they are the way to reach support.
private func pendingApprovalList(message: String, members: [HealthChatMember]) -> some View {
Form {
Section {
ListRow(label: .default(title: HealthChatL10n.screenMemberDirectoryPendingTitle,
description: message,
icon: \.time),
kind: .label)
// Without the search field there is no other way to pick up an approval
// that arrives while the app is running.
ListRow(label: .default(title: HealthChatL10n.actionRefresh, icon: \.restart),
kind: .button {
context.send(viewAction: .refresh)
})
}
memberSection(members)
}
.compoundList()
}
private func memberSection(_ members: [HealthChatMember]) -> some View {
Section {
ForEach(members) { member in
HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) {
context.send(viewAction: .selectMember(member))
} startChatAction: {
context.send(viewAction: .startChat(member))
}
}
}
}
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<CompoundIcons, Image>, message: String) -> some View {
statusContent(icon: icon, message: message)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
}
private func statusContent(icon: KeyPath<CompoundIcons, Image>, 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:
// The server hands an unapproved account its default contacts, nothing else.
let defaultContacts = try? JSONDecoder().decode([HealthChatMember].self, from: Data("""
[
{
"userId": "@natascha.katumba:healthchat.ch",
"displayName": "Natascha Katumba",
"practiceName": "HealthChat Support"
},
{
"userId": "@sophia.cagna:healthchat.ch",
"displayName": "Sophia Cagna",
"practiceName": "HealthChat Support"
}
]
""".utf8))
apiService.membersSearchQueryReturnValue = .success(.init(users: defaultContacts ?? [],
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())
}
}