mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +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.
|
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||||
|
"action_refresh" = "Aktualisieren";
|
||||||
"common_academic_title" = "Titel";
|
"common_academic_title" = "Titel";
|
||||||
"common_address" = "Adresse";
|
"common_address" = "Adresse";
|
||||||
"common_business_email" = "E-Mail (geschäftlich)";
|
"common_business_email" = "E-Mail (geschäftlich)";
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"screen_invite_member_success" = "Einladung wurde versendet";
|
"screen_invite_member_success" = "Einladung wurde versendet";
|
||||||
"screen_invite_member_title" = "Mitglied einladen";
|
"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_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_search_placeholder" = "Name, Praxis, Ort oder Fachgebiet";
|
||||||
"screen_member_directory_title" = "Mitglieder";
|
"screen_member_directory_title" = "Mitglieder";
|
||||||
"screen_profile_edit_section_address" = "Praxisadresse";
|
"screen_profile_edit_section_address" = "Praxisadresse";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||||
|
"action_refresh" = "Refresh";
|
||||||
"common_academic_title" = "Title";
|
"common_academic_title" = "Title";
|
||||||
"common_address" = "Address";
|
"common_address" = "Address";
|
||||||
"common_business_email" = "Business email";
|
"common_business_email" = "Business email";
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"screen_invite_member_success" = "Invitation sent";
|
"screen_invite_member_success" = "Invitation sent";
|
||||||
"screen_invite_member_title" = "Invite a member";
|
"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_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_search_placeholder" = "Name, practice, city or speciality";
|
||||||
"screen_member_directory_title" = "Members";
|
"screen_member_directory_title" = "Members";
|
||||||
"screen_profile_edit_section_address" = "Practice address";
|
"screen_profile_edit_section_address" = "Practice address";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||||
|
"action_refresh" = "Actualiser";
|
||||||
"common_academic_title" = "Titre";
|
"common_academic_title" = "Titre";
|
||||||
"common_address" = "Adresse";
|
"common_address" = "Adresse";
|
||||||
"common_business_email" = "Email (professionnel)";
|
"common_business_email" = "Email (professionnel)";
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"screen_invite_member_success" = "Invitation envoyée";
|
"screen_invite_member_success" = "Invitation envoyée";
|
||||||
"screen_invite_member_title" = "Inviter un membre";
|
"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_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_search_placeholder" = "Nom, cabinet, ville ou spécialisation";
|
||||||
"screen_member_directory_title" = "Membres";
|
"screen_member_directory_title" = "Membres";
|
||||||
"screen_profile_edit_section_address" = "Adresse du cabinet";
|
"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 explicit_type_interface function_parameter_count identifier_name line_length
|
||||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||||
internal nonisolated enum HealthChatL10n {
|
internal nonisolated enum HealthChatL10n {
|
||||||
|
/// Refresh
|
||||||
|
internal static var actionRefresh: String { return HealthChatL10n.tr("HealthChat", "action_refresh") }
|
||||||
/// Title
|
/// Title
|
||||||
internal static var commonAcademicTitle: String { return HealthChatL10n.tr("HealthChat", "common_academic_title") }
|
internal static var commonAcademicTitle: String { return HealthChatL10n.tr("HealthChat", "common_academic_title") }
|
||||||
/// Address
|
/// Address
|
||||||
@@ -50,6 +52,8 @@ internal nonisolated enum HealthChatL10n {
|
|||||||
internal static var screenInviteMemberTitle: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_title") }
|
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.
|
/// 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") }
|
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
|
/// Name, practice, city or speciality
|
||||||
internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") }
|
internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") }
|
||||||
/// Members
|
/// Members
|
||||||
|
|||||||
+4
-2
@@ -17,14 +17,15 @@ enum HealthChatMemberDirectoryScreenViewModelAction {
|
|||||||
nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable {
|
nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable {
|
||||||
case loading
|
case loading
|
||||||
case members([HealthChatMember])
|
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
|
case failure
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HealthChatMemberDirectoryScreenViewState: BindableState {
|
struct HealthChatMemberDirectoryScreenViewState: BindableState {
|
||||||
var content: HealthChatMemberDirectoryScreenContent = .loading
|
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
|
var isApproved = false
|
||||||
|
|
||||||
/// The hint shown while the own profile is missing required fields.
|
/// The hint shown while the own profile is missing required fields.
|
||||||
@@ -44,6 +45,7 @@ enum HealthChatMemberDirectoryScreenAlertType: Hashable {
|
|||||||
|
|
||||||
enum HealthChatMemberDirectoryScreenViewAction {
|
enum HealthChatMemberDirectoryScreenViewAction {
|
||||||
case retry
|
case retry
|
||||||
|
case refresh
|
||||||
case selectMember(HealthChatMember)
|
case selectMember(HealthChatMember)
|
||||||
case startChat(HealthChatMember)
|
case startChat(HealthChatMember)
|
||||||
case editProfile
|
case editProfile
|
||||||
|
|||||||
+4
-1
@@ -60,6 +60,8 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV
|
|||||||
case .retry:
|
case .retry:
|
||||||
state.content = .loading
|
state.content = .loading
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
|
case .refresh:
|
||||||
|
fetchMembers()
|
||||||
case .selectMember(let member):
|
case .selectMember(let member):
|
||||||
actionsSubject.send(.selectMember(userID: member.userID))
|
actionsSubject.send(.selectMember(userID: member.userID))
|
||||||
case .startChat(let member):
|
case .startChat(let member):
|
||||||
@@ -86,7 +88,8 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV
|
|||||||
case .success(let response) where !response.isApproved:
|
case .success(let response) where !response.isApproved:
|
||||||
state.isApproved = false
|
state.isApproved = false
|
||||||
state.completenessMessage = nil
|
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):
|
case .success(let response):
|
||||||
state.isApproved = true
|
state.isApproved = true
|
||||||
await updateCompletenessMessage()
|
await updateCompletenessMessage()
|
||||||
|
|||||||
+63
-12
@@ -12,12 +12,21 @@ struct HealthChatMemberDirectoryScreen: View {
|
|||||||
@Bindable var context: HealthChatMemberDirectoryScreenViewModel.Context
|
@Bindable var context: HealthChatMemberDirectoryScreenViewModel.Context
|
||||||
|
|
||||||
var body: some View {
|
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
|
content
|
||||||
.navigationTitle(HealthChatL10n.screenMemberDirectoryTitle)
|
.navigationTitle(HealthChatL10n.screenMemberDirectoryTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||||
.searchController(query: $context.searchQuery,
|
|
||||||
placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder)
|
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
}
|
}
|
||||||
@@ -53,8 +62,8 @@ struct HealthChatMemberDirectoryScreen: View {
|
|||||||
case .loading:
|
case .loading:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
case .pendingApproval(let message):
|
case .pendingApproval(let message, let members):
|
||||||
statusView(icon: \.time, message: message)
|
pendingApprovalList(message: message, members: members)
|
||||||
case .failure:
|
case .failure:
|
||||||
failureView
|
failureView
|
||||||
case .members(let members) where members.isEmpty:
|
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 {
|
Section {
|
||||||
ForEach(members) { member in
|
ListRow(label: .default(title: HealthChatL10n.screenMemberDirectoryPendingTitle,
|
||||||
HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) {
|
description: message,
|
||||||
context.send(viewAction: .selectMember(member))
|
icon: \.time),
|
||||||
} startChatAction: {
|
kind: .label)
|
||||||
context.send(viewAction: .startChat(member))
|
|
||||||
}
|
// 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 {
|
private var failureView: some View {
|
||||||
@@ -202,7 +238,22 @@ struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePrevie
|
|||||||
apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self,
|
apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self,
|
||||||
from: Data(#"{ "isComplete": false, "message": "Profil unvollständig – Fehlende Angaben: Fachgebiet, Ort" }"#.utf8)))
|
from: Data(#"{ "isComplete": false, "message": "Profil unvollständig – Fehlende Angaben: Fachgebiet, Ort" }"#.utf8)))
|
||||||
case .pendingApproval:
|
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,
|
isApproved: false,
|
||||||
pendingMessage: "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde."))
|
pendingMessage: "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde."))
|
||||||
case .failure:
|
case .failure:
|
||||||
|
|||||||
@@ -31,19 +31,46 @@ final class HealthChatMemberDirectoryScreenViewModelTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func showsPendingApprovalMessage() async throws {
|
func showsPendingApprovalMessageWithTheDefaultContacts() async throws {
|
||||||
apiService.membersSearchQueryReturnValue = .success(.init(users: [],
|
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"],
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
pendingMessage: "Ihr Konto wird geprüft"))
|
pendingMessage: "Ihr Konto wird geprüft"))
|
||||||
|
|
||||||
let viewModel = makeViewModel()
|
let viewModel = makeViewModel()
|
||||||
|
|
||||||
let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
|
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()
|
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
|
@Test
|
||||||
func showsFailureAndRecoversOnRetry() async throws {
|
func showsFailureAndRecoversOnRetry() async throws {
|
||||||
apiService.membersSearchQueryReturnValue = .failure(.networkFailure)
|
apiService.membersSearchQueryReturnValue = .failure(.networkFailure)
|
||||||
@@ -160,10 +187,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func pendingApprovalDisablesInviting() async throws {
|
func pendingApprovalDisablesSearchingAndInviting() async throws {
|
||||||
apiService.membersSearchQueryReturnValue = .success(.init(users: [],
|
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [],
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
pendingMessage: "Ihr Konto wird geprüft"))
|
pendingMessage: "Ihr Konto wird geprüft"))
|
||||||
|
|
||||||
let viewModel = makeViewModel()
|
let viewModel = makeViewModel()
|
||||||
|
|
||||||
@@ -220,8 +247,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests {
|
|||||||
userIndicatorController: UserIndicatorControllerMock())
|
userIndicatorController: UserIndicatorControllerMock())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeResponse(userIDs: [String]) throws -> HealthChatMembersResponse {
|
private func makeResponse(userIDs: [String],
|
||||||
try .init(users: userIDs.map(makeMember(userID:)), isApproved: true, pendingMessage: nil)
|
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 {
|
private func makeMember(userID: String) throws -> HealthChatMember {
|
||||||
|
|||||||
Reference in New Issue
Block a user