mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||
"action_refresh" = "Aktualisieren";
|
||||
"common_academic_title" = "Titel";
|
||||
"common_address" = "Adresse";
|
||||
"common_business_email" = "E-Mail (geschäftlich)";
|
||||
@@ -19,6 +20,7 @@
|
||||
"screen_invite_member_success" = "Einladung wurde versendet";
|
||||
"screen_invite_member_title" = "Mitglied einladen";
|
||||
"screen_member_directory_pending_approval" = "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde.";
|
||||
"screen_member_directory_pending_title" = "Konto wird geprüft";
|
||||
"screen_member_directory_search_placeholder" = "Name, Praxis, Ort oder Fachgebiet";
|
||||
"screen_member_directory_title" = "Mitglieder";
|
||||
"screen_profile_edit_section_address" = "Praxisadresse";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||
"action_refresh" = "Refresh";
|
||||
"common_academic_title" = "Title";
|
||||
"common_address" = "Address";
|
||||
"common_business_email" = "Business email";
|
||||
@@ -19,6 +20,7 @@
|
||||
"screen_invite_member_success" = "Invitation sent";
|
||||
"screen_invite_member_title" = "Invite a member";
|
||||
"screen_member_directory_pending_approval" = "Your account is being reviewed. You’ll get access to the member directory once it has been approved.";
|
||||
"screen_member_directory_pending_title" = "Account under review";
|
||||
"screen_member_directory_search_placeholder" = "Name, practice, city or speciality";
|
||||
"screen_member_directory_title" = "Members";
|
||||
"screen_profile_edit_section_address" = "Practice address";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||
"action_refresh" = "Actualiser";
|
||||
"common_academic_title" = "Titre";
|
||||
"common_address" = "Adresse";
|
||||
"common_business_email" = "Email (professionnel)";
|
||||
@@ -19,6 +20,7 @@
|
||||
"screen_invite_member_success" = "Invitation envoyée";
|
||||
"screen_invite_member_title" = "Inviter un membre";
|
||||
"screen_member_directory_pending_approval" = "Votre compte est en cours de vérification. Vous aurez accès à l’annuaire des membres dès qu’il aura été approuvé.";
|
||||
"screen_member_directory_pending_title" = "Compte en cours de vérification";
|
||||
"screen_member_directory_search_placeholder" = "Nom, cabinet, ville ou spécialisation";
|
||||
"screen_member_directory_title" = "Membres";
|
||||
"screen_profile_edit_section_address" = "Adresse du cabinet";
|
||||
|
||||
@@ -10,6 +10,8 @@ import Foundation
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
internal nonisolated enum HealthChatL10n {
|
||||
/// Refresh
|
||||
internal static var actionRefresh: String { return HealthChatL10n.tr("HealthChat", "action_refresh") }
|
||||
/// Title
|
||||
internal static var commonAcademicTitle: String { return HealthChatL10n.tr("HealthChat", "common_academic_title") }
|
||||
/// Address
|
||||
@@ -50,6 +52,8 @@ internal nonisolated enum HealthChatL10n {
|
||||
internal static var screenInviteMemberTitle: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_title") }
|
||||
/// Your account is being reviewed. You’ll get access to the member directory once it has been approved.
|
||||
internal static var screenMemberDirectoryPendingApproval: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_pending_approval") }
|
||||
/// Account under review
|
||||
internal static var screenMemberDirectoryPendingTitle: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_pending_title") }
|
||||
/// Name, practice, city or speciality
|
||||
internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") }
|
||||
/// Members
|
||||
|
||||
+4
-2
@@ -17,14 +17,15 @@ enum HealthChatMemberDirectoryScreenViewModelAction {
|
||||
nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable {
|
||||
case loading
|
||||
case members([HealthChatMember])
|
||||
case pendingApproval(message: String)
|
||||
/// Not approved yet: the server's explanation alongside the default contacts it still allows.
|
||||
case pendingApproval(message: String, members: [HealthChatMember])
|
||||
case failure
|
||||
}
|
||||
|
||||
struct HealthChatMemberDirectoryScreenViewState: BindableState {
|
||||
var content: HealthChatMemberDirectoryScreenContent = .loading
|
||||
|
||||
/// Inviting others is only available to approved accounts.
|
||||
/// Searching and inviting others are only available to approved accounts.
|
||||
var isApproved = false
|
||||
|
||||
/// The hint shown while the own profile is missing required fields.
|
||||
@@ -44,6 +45,7 @@ enum HealthChatMemberDirectoryScreenAlertType: Hashable {
|
||||
|
||||
enum HealthChatMemberDirectoryScreenViewAction {
|
||||
case retry
|
||||
case refresh
|
||||
case selectMember(HealthChatMember)
|
||||
case startChat(HealthChatMember)
|
||||
case editProfile
|
||||
|
||||
+4
-1
@@ -60,6 +60,8 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV
|
||||
case .retry:
|
||||
state.content = .loading
|
||||
fetchMembers()
|
||||
case .refresh:
|
||||
fetchMembers()
|
||||
case .selectMember(let member):
|
||||
actionsSubject.send(.selectMember(userID: member.userID))
|
||||
case .startChat(let member):
|
||||
@@ -86,7 +88,8 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV
|
||||
case .success(let response) where !response.isApproved:
|
||||
state.isApproved = false
|
||||
state.completenessMessage = nil
|
||||
state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval)
|
||||
state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval,
|
||||
members: response.users)
|
||||
case .success(let response):
|
||||
state.isApproved = true
|
||||
await updateCompletenessMessage()
|
||||
|
||||
+63
-12
@@ -12,12 +12,21 @@ 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())
|
||||
.searchController(query: $context.searchQuery,
|
||||
placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
@@ -53,8 +62,8 @@ struct HealthChatMemberDirectoryScreen: View {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .pendingApproval(let message):
|
||||
statusView(icon: \.time, message: message)
|
||||
case .pendingApproval(let message, let members):
|
||||
pendingApprovalList(message: message, members: members)
|
||||
case .failure:
|
||||
failureView
|
||||
case .members(let members) where members.isEmpty:
|
||||
@@ -75,17 +84,44 @@ struct HealthChatMemberDirectoryScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
ForEach(members) { member in
|
||||
HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .selectMember(member))
|
||||
} startChatAction: {
|
||||
context.send(viewAction: .startChat(member))
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
}
|
||||
|
||||
private var failureView: some View {
|
||||
@@ -202,7 +238,22 @@ struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePrevie
|
||||
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: [],
|
||||
// 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:
|
||||
|
||||
@@ -31,19 +31,46 @@ final class HealthChatMemberDirectoryScreenViewModelTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func showsPendingApprovalMessage() async throws {
|
||||
apiService.membersSearchQueryReturnValue = .success(.init(users: [],
|
||||
isApproved: false,
|
||||
pendingMessage: "Ihr Konto wird geprüft"))
|
||||
func showsPendingApprovalMessageWithTheDefaultContacts() async throws {
|
||||
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"],
|
||||
isApproved: false,
|
||||
pendingMessage: "Ihr Konto wird geprüft"))
|
||||
|
||||
let viewModel = makeViewModel()
|
||||
|
||||
let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
|
||||
content == .pendingApproval(message: "Ihr Konto wird geprüft")
|
||||
guard case .pendingApproval(let message, let members) = content else { return false }
|
||||
return message == "Ihr Konto wird geprüft" && members.map(\.userID) == ["@natascha.katumba:healthchat.ch"]
|
||||
}
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
@Test
|
||||
func refreshPicksUpAnApprovalWithoutClearingTheList() async throws {
|
||||
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"],
|
||||
isApproved: false,
|
||||
pendingMessage: "Ihr Konto wird geprüft"))
|
||||
|
||||
let viewModel = makeViewModel()
|
||||
|
||||
let deferredPending = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
|
||||
guard case .pendingApproval = content else { return false }
|
||||
return true
|
||||
}
|
||||
try await deferredPending.fulfill()
|
||||
|
||||
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"]))
|
||||
|
||||
let deferredMembers = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
|
||||
guard case .members(let members) = content else { return false }
|
||||
return members.map(\.userID) == ["@alice:healthchat.ch"]
|
||||
}
|
||||
viewModel.context.send(viewAction: .refresh)
|
||||
try await deferredMembers.fulfill()
|
||||
|
||||
#expect(viewModel.context.viewState.isApproved)
|
||||
}
|
||||
|
||||
@Test
|
||||
func showsFailureAndRecoversOnRetry() async throws {
|
||||
apiService.membersSearchQueryReturnValue = .failure(.networkFailure)
|
||||
@@ -160,10 +187,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingApprovalDisablesInviting() async throws {
|
||||
apiService.membersSearchQueryReturnValue = .success(.init(users: [],
|
||||
isApproved: false,
|
||||
pendingMessage: "Ihr Konto wird geprüft"))
|
||||
func pendingApprovalDisablesSearchingAndInviting() async throws {
|
||||
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [],
|
||||
isApproved: false,
|
||||
pendingMessage: "Ihr Konto wird geprüft"))
|
||||
|
||||
let viewModel = makeViewModel()
|
||||
|
||||
@@ -220,8 +247,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests {
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
}
|
||||
|
||||
private func makeResponse(userIDs: [String]) throws -> HealthChatMembersResponse {
|
||||
try .init(users: userIDs.map(makeMember(userID:)), isApproved: true, pendingMessage: nil)
|
||||
private func makeResponse(userIDs: [String],
|
||||
isApproved: Bool = true,
|
||||
pendingMessage: String? = nil) throws -> HealthChatMembersResponse {
|
||||
try .init(users: userIDs.map(makeMember(userID:)), isApproved: isApproved, pendingMessage: pendingMessage)
|
||||
}
|
||||
|
||||
private func makeMember(userID: String) throws -> HealthChatMember {
|
||||
|
||||
Reference in New Issue
Block a user