mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
b70e77f7c5
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.
269 lines
11 KiB
Swift
269 lines
11 KiB
Swift
//
|
||
// 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())
|
||
}
|
||
}
|