feat(healthchat): advertise the account status in settings

Pending approval and an incomplete profile were only visible in the
members tab, which a new user has no reason to open.
This commit is contained in:
Sedat Qaja
2026-10-09 14:39:33 +02:00
parent b70e77f7c5
commit d01d04846a
22 changed files with 349 additions and 2 deletions
@@ -1,4 +1,5 @@
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
"action_complete_profile" = "Profil vervollständigen";
"action_refresh" = "Aktualisieren";
"common_academic_title" = "Titel";
"common_address" = "Adresse";
@@ -11,6 +12,7 @@
"common_last_name" = "Nachname";
"common_postal_code" = "PLZ";
"common_practice_name" = "Praxisname / Firmenname";
"common_profile_incomplete" = "Profil unvollständig";
"common_specialty" = "Fachgebiet";
"common_street" = "Strasse";
"common_website" = "Webseite";
@@ -1,4 +1,5 @@
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
"action_complete_profile" = "Complete profile";
"action_refresh" = "Refresh";
"common_academic_title" = "Title";
"common_address" = "Address";
@@ -11,6 +12,7 @@
"common_last_name" = "Last name";
"common_postal_code" = "Postal code";
"common_practice_name" = "Practice name";
"common_profile_incomplete" = "Profile incomplete";
"common_specialty" = "Speciality";
"common_street" = "Street";
"common_website" = "Website";
@@ -1,4 +1,5 @@
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
"action_complete_profile" = "Compléter le profil";
"action_refresh" = "Actualiser";
"common_academic_title" = "Titre";
"common_address" = "Adresse";
@@ -11,6 +12,7 @@
"common_last_name" = "Nom";
"common_postal_code" = "Code postal";
"common_practice_name" = "Nom de l’entreprise";
"common_profile_incomplete" = "Profil incomplet";
"common_specialty" = "Spécialisation";
"common_street" = "Rue";
"common_website" = "Site web";
+4
View File
@@ -15,6 +15,7 @@ final nonisolated class AppHooks: AppHooksProtocol {
func configure(with userSession: UserSessionProtocol?) async {
await userSessionHook.configure(with: userSession)
await extendedProfileSectionHook.configure(with: userSession)
await settingsScreenHook.configure(with: userSession)
}
@AppHook(default: DefaultAppSettingsHook())
@@ -50,6 +51,9 @@ final nonisolated class AppHooks: AppHooksProtocol {
@AppHook(default: DefaultHomeTabsHook())
var homeTabsHook: HomeTabsHookProtocol
@AppHook(default: DefaultSettingsScreenHook())
var settingsScreenHook: SettingsScreenHookProtocol
@AppHook(default: DefaultUserDetailsEditScreenHook())
var userDetailsEditScreenHook: UserDetailsEditScreenHookProtocol
@@ -0,0 +1,53 @@
//
// 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 Foundation
import Observation
/// Loads the own account's status for the banners that advertise it.
@Observable
final class HealthChatAccountStatusLoader {
private(set) var status: HealthChatAccountStatus = .unknown
/// The load kicked off on creation — await this in tests before asserting.
@ObservationIgnored private(set) var loadingTask: Task<Void, Never>?
init(apiService: HealthChatAPIServiceProtocol) {
loadingTask = Task { await load(from: apiService) }
}
/// Creates a loader with a fixed status, for previews.
init(status: HealthChatAccountStatus) {
self.status = status
}
// MARK: - Private
private func load(from apiService: HealthChatAPIServiceProtocol) async {
// Every failure below leaves the status unknown: a banner must not report an error.
guard case let .success(profile) = await apiService.ownProfile() else {
return
}
guard profile.isApproved else {
status = .pendingApproval
return
}
guard case let .success(completeness) = await apiService.profileCompleteness() else {
return
}
// The server words the hint itself; without one there is nothing to show.
guard !completeness.isComplete, let message = completeness.message else {
status = .ok
return
}
status = .incompleteProfile(message: message)
}
}
@@ -0,0 +1,67 @@
//
// 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
/// The account status advertised in the app's settings. Renders nothing while loading,
/// for approved accounts with a complete profile, and when the status can't be read.
///
/// Neither banner is dismissible — both resolve server-side.
struct HealthChatAccountStatusSection: View {
// Owned by the hook, not the view: the settings screen rebuilds this section on
// every body pass, and a view-owned loader would fire a request each time.
private let loader: HealthChatAccountStatusLoader
private let completeProfileAction: () -> Void
init(loader: HealthChatAccountStatusLoader, completeProfileAction: @escaping () -> Void = { }) {
self.loader = loader
self.completeProfileAction = completeProfileAction
}
var body: some View {
switch loader.status {
case .unknown, .ok:
EmptyView()
case .pendingApproval:
Section {
ListRow(label: .default(title: HealthChatL10n.screenMemberDirectoryPendingTitle,
description: HealthChatL10n.screenMemberDirectoryPendingApproval,
icon: \.time),
kind: .label)
}
case .incompleteProfile(let message):
Section {
ListRow(label: .default(title: HealthChatL10n.commonProfileIncomplete,
description: message,
icon: \.info),
kind: .label)
ListRow(label: .action(title: HealthChatL10n.actionCompleteProfile, icon: \.userProfile),
kind: .button(action: completeProfileAction))
}
}
}
}
// MARK: - Previews
struct HealthChatAccountStatusSection_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
HealthChatAccountStatusSection(loader: .init(status: .pendingApproval))
}
.compoundList()
.previewDisplayName("Pending approval")
Form {
HealthChatAccountStatusSection(loader: .init(status: .incompleteProfile(message: "Bitte vervollständigen Sie Ihr Profil, bevor Sie die Benutzersuche verwenden")))
}
.compoundList()
.previewDisplayName("Incomplete profile")
}
}
@@ -18,6 +18,7 @@ extension AppHooks {
registerCompoundHook(HealthChatCompoundHook())
registerExtendedProfileSectionHook(HealthChatExtendedProfileSectionHook())
registerHomeTabsHook(HealthChatHomeTabsHook())
registerSettingsScreenHook(HealthChatSettingsScreenHook())
registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook())
#endif
}
@@ -0,0 +1,36 @@
//
// 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 SwiftUI
#if IS_MAIN_APP
// Conforming to the nonisolated hook protocol would infer nonisolated, but the
// service is main-actor state — the hook's requirements are all @MainActor anyway.
@MainActor
final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol {
/// One load per session — the settings screen asks for this section on every body pass.
private var statusLoader: HealthChatAccountStatusLoader?
// Constructed during hook registration, before the main actor is reachable.
nonisolated init() { }
func configure(with userSession: UserSessionProtocol?) async {
statusLoader = userSession.map {
let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: $0.clientProxy))
return HealthChatAccountStatusLoader(apiService: apiService)
}
}
func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? {
guard let statusLoader else {
return nil
}
return AnyView(HealthChatAccountStatusSection(loader: statusLoader,
completeProfileAction: completeProfileAction))
}
}
#endif
@@ -0,0 +1,25 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
nonisolated protocol SettingsScreenHookProtocol: Sendable {
@MainActor func configure(with userSession: UserSessionProtocol?) async
/// An extra Form section rendered between the user and the account sections,
/// advertising whatever is holding the account back.
/// - Parameter completeProfileAction: Opens the own profile editor.
@MainActor func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView?
}
struct DefaultSettingsScreenHook: SettingsScreenHookProtocol {
func configure(with userSession: UserSessionProtocol?) async { }
func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? {
nil
}
}
@@ -74,6 +74,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private func presentSettingsScreen(animated: Bool) {
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: flowParameters.userSession,
appSettings: flowParameters.appSettings,
appHooks: flowParameters.appHooks,
isBugReportServiceEnabled: flowParameters.bugReportService.isEnabled,
isInSecondaryWindow: isInSecondaryWindow,
userIndicatorController: flowParameters.userIndicatorController))
@@ -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 {
/// Complete profile
internal static var actionCompleteProfile: String { return HealthChatL10n.tr("HealthChat", "action_complete_profile") }
/// Refresh
internal static var actionRefresh: String { return HealthChatL10n.tr("HealthChat", "action_refresh") }
/// Title
@@ -34,6 +36,8 @@ internal nonisolated enum HealthChatL10n {
internal static var commonPostalCode: String { return HealthChatL10n.tr("HealthChat", "common_postal_code") }
/// Practice name
internal static var commonPracticeName: String { return HealthChatL10n.tr("HealthChat", "common_practice_name") }
/// Profile incomplete
internal static var commonProfileIncomplete: String { return HealthChatL10n.tr("HealthChat", "common_profile_incomplete") }
/// Speciality
internal static var commonSpecialty: String { return HealthChatL10n.tr("HealthChat", "common_specialty") }
/// Street
@@ -56,6 +56,7 @@ enum TestablePreviewsDictionary {
"FormattingToolbar_Previews" : FormattingToolbar_Previews.self,
"FullscreenDialog_Previews" : FullscreenDialog_Previews.self,
"GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self,
"HealthChatAccountStatusSection_Previews" : HealthChatAccountStatusSection_Previews.self,
"HealthChatExtendedProfileSection_Previews" : HealthChatExtendedProfileSection_Previews.self,
"HealthChatInviteMemberScreen_Previews" : HealthChatInviteMemberScreen_Previews.self,
"HealthChatMemberDirectoryScreen_Previews" : HealthChatMemberDirectoryScreen_Previews.self,
@@ -12,6 +12,7 @@ import SwiftUI
struct SettingsScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let appSettings: AppSettings
let appHooks: AppHooks
let isBugReportServiceEnabled: Bool
let isInSecondaryWindow: Bool
let userIndicatorController: UserIndicatorControllerProtocol
@@ -52,6 +53,7 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
init(parameters: SettingsScreenCoordinatorParameters) {
viewModel = SettingsScreenViewModel(userSession: parameters.userSession,
appSettings: parameters.appSettings,
appHooks: parameters.appHooks,
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
isInSecondaryWindow: parameters.isInSecondaryWindow,
userIndicatorController: parameters.userIndicatorController)
@@ -52,6 +52,8 @@ struct SettingsScreenViewState: BindableState {
let navigationBarVisibility: Visibility
let appHooks: AppHooks
var bindings = SettingsScreenViewStateBindings()
var userStatusRowMode: SettingsScreenUserStatusRow.Mode {
@@ -22,7 +22,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
actionsSubject.eraseToAnyPublisher()
}
init(userSession: UserSessionProtocol, appSettings: AppSettings, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) {
init(userSession: UserSessionProtocol, appSettings: AppSettings, appHooks: AppHooks, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) {
self.appSettings = appSettings
clientProxy = userSession.clientProxy
self.userIndicatorController = userIndicatorController
@@ -34,7 +34,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
showDeveloperOptions: appSettings.developerOptionsEnabled,
showAnalyticsSettings: appSettings.canPromptForAnalytics,
isBugReportServiceEnabled: isBugReportServiceEnabled,
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic),
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic,
appHooks: appHooks),
mediaProvider: userSession.mediaProvider)
appSettings.developerOptionsEnabledPublisher
@@ -27,6 +27,10 @@ struct SettingsScreen: View {
userStatusSection
}
context.viewState.appHooks.settingsScreenHook.accountStatusSection {
context.send(viewAction: .userDetails)
}
if !shouldHideManageAccountSection {
manageAccountSection
}
@@ -314,6 +318,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
status: .mockFocussing))))
return SettingsScreenViewModel(userSession: userSession,
appSettings: .volatile(),
appHooks: AppHooks(),
isBugReportServiceEnabled: isBugReportServiceEnabled,
isInSecondaryWindow: false,
userIndicatorController: UserIndicatorControllerMock())
@@ -0,0 +1,20 @@
//
// 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 Foundation
/// What is holding an account back from using the whole app.
nonisolated enum HealthChatAccountStatus: Equatable {
/// Nothing is known, e.g. the API is unreachable. Renders nothing — never an error.
case unknown
/// Not approved by an admin yet. Takes precedence over ``incompleteProfile``,
/// as completing a profile does nothing for an unapproved account.
case pendingApproval
/// Approved, but the server reports missing profile fields. The message is its wording.
case incompleteProfile(message: String)
case ok
}