Add profile editor, member invite and status banners (#4)

* feat(healthchat): add profile editor, member invite and status banners

Native 13-field profile editor with diff-only saves and avatar upload
through the HealthChat API (replaces the upstream display-name editor),
email invitation for approved accounts, and non-dismissible account
status banners in settings and the member directory.

* fix(healthchat): satisfy detekt and konsist rules

Konsist presenter helper shape, no '=' in state parameter docs, present-tense invite callback, and complete renderer KDoc.
This commit is contained in:
2026-08-06 13:28:24 +02:00
committed by Sedat Qaja
parent 1cf01cdc62
commit eabfca5c75
74 changed files with 2947 additions and 26 deletions
+2 -1
View File
@@ -7,6 +7,7 @@
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
@@ -16,7 +17,7 @@ android {
dependencies {
api(libs.kotlinx.collections.immutable)
implementation(libs.coroutines.core)
implementation(projects.libraries.architecture)
api(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
api(projects.libraries.matrix.api)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Renders the HealthChat account status banners ("account under review", "profile incomplete").
*
* Both states resolve server-side (approval, completed profile), so the banners are
* deliberately not dismissible. When nothing needs attention, nothing is rendered.
*/
fun interface HealthChatAccountStatusRenderer {
/**
* @param onCompleteProfileClick called when the user taps the banner action to complete their profile.
* @param includePendingApproval false when the host screen already shows its own
* pending-approval banner (e.g. the member directory with the verbatim server message).
* @param modifier the modifier to apply to the banner.
*/
@Composable
fun Render(
onCompleteProfileClick: () -> Unit,
includePendingApproval: Boolean,
modifier: Modifier,
)
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import kotlinx.parcelize.Parcelize
/**
* Entry point into the fork-owned HealthChat flow (profile editor, member invitation).
*/
fun interface HealthChatEntryPoint : FeatureEntryPoint {
data class Params(
val initialTarget: InitialTarget,
) : NodeInputs
sealed interface InitialTarget : Parcelable {
@Parcelize
data object EditOwnProfile : InitialTarget
@Parcelize
data object Invite : InitialTarget
}
interface Callback : Plugin {
fun onDone()
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: Params,
callback: Callback,
): Node
}
@@ -37,4 +37,46 @@
<string name="healthchat_a11y_refresh">Aktualisieren</string>
<string name="healthchat_a11y_edit_profile">Profil bearbeiten</string>
<string name="healthchat_a11y_invite_member">Neues Mitglied einladen</string>
<string name="healthchat_field_name">Anzeigename</string>
<string name="healthchat_field_title">Titel</string>
<string name="healthchat_field_title_placeholder">z.B. Dr. med.</string>
<string name="healthchat_field_first_name">Vorname</string>
<string name="healthchat_field_last_name">Nachname</string>
<string name="healthchat_field_specialty_placeholder">z.B. Allgemeinmedizin</string>
<string name="healthchat_field_practice_name_placeholder">z.B. Praxis Muster</string>
<string name="healthchat_field_city">Ort</string>
<string name="healthchat_field_city_placeholder">z.B. Zürich</string>
<string name="healthchat_field_business_email_placeholder">praxis@beispiel.ch</string>
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
<!-- Swiss German: "Strasse" (no ß) is correct for this product. -->
<string name="healthchat_field_street" tools:ignore="Typos">Strasse</string>
<string name="healthchat_field_house_number">Nr.</string>
<string name="healthchat_field_postal_code">PLZ</string>
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
<string name="healthchat_screen_edit_profile_title">Profil bearbeiten</string>
<string name="healthchat_screen_edit_profile_section_personal">Persönliche Angaben</string>
<string name="healthchat_screen_edit_profile_section_professional">Berufliche Angaben</string>
<string name="healthchat_screen_edit_profile_section_contact">Kontaktdaten</string>
<string name="healthchat_screen_edit_profile_section_address">Praxisadresse</string>
<string name="healthchat_screen_edit_profile_saving">Profil wird gespeichert…</string>
<string name="healthchat_screen_edit_profile_error_title">Profil konnte nicht gespeichert werden</string>
<string name="healthchat_screen_edit_profile_load_error">Das Profil konnte nicht geladen werden.</string>
<string name="healthchat_screen_edit_profile_recommended">Für ein vollständiges Profil empfohlen</string>
<string name="healthchat_error_field_required">Dieses Feld ist erforderlich</string>
<string name="healthchat_error_invalid_email">Bitte eine gültige E-Mail-Adresse eingeben</string>
<string name="healthchat_error_invalid_url">Bitte eine gültige URL eingeben (z.B. https://praxis-muster.ch)</string>
<string name="healthchat_error_note_too_short">Die persönliche Nachricht muss mindestens 10 Zeichen lang sein</string>
<string name="healthchat_error_avatar_too_large">Das Bild darf höchstens 2 MB gross sein.</string>
<string name="healthchat_banner_incomplete_title">Profil unvollständig</string>
<string name="healthchat_banner_incomplete_description">Bitte vervollständigen Sie Ihr Profil, damit andere Mitglieder Sie finden können. Fehlende Angaben: %1$s</string>
<string name="healthchat_banner_incomplete_action">Profil vervollständigen</string>
<string name="healthchat_screen_invite_title">Mitglied einladen</string>
<string name="healthchat_screen_invite_description">Laden Sie neue Mitglieder zu HealthChat ein, indem Sie deren E-Mail-Adresse und eine persönliche Nachricht eingeben.</string>
<string name="healthchat_screen_invite_email_label">E-Mail-Adresse</string>
<string name="healthchat_screen_invite_email_placeholder">beispiel@domain.ch</string>
<string name="healthchat_screen_invite_note_label">Persönliche Nachricht</string>
<string name="healthchat_screen_invite_note_placeholder">Schreiben Sie eine persönliche Nachricht an das neue Mitglied…</string>
<string name="healthchat_screen_invite_note_counter">%1$d/10 Zeichen</string>
<string name="healthchat_screen_invite_send">Einladung senden</string>
<string name="healthchat_screen_invite_sending">Einladung wird gesendet…</string>
</resources>
@@ -38,4 +38,45 @@
<string name="healthchat_a11y_refresh">Actualiser</string>
<string name="healthchat_a11y_edit_profile">Modifier le profil</string>
<string name="healthchat_a11y_invite_member">Inviter un nouveau membre</string>
<string name="healthchat_field_name">Nom affiché</string>
<string name="healthchat_field_title">Titre</string>
<string name="healthchat_field_title_placeholder">p. ex. Dr méd.</string>
<string name="healthchat_field_first_name">Prénom</string>
<string name="healthchat_field_last_name">Nom</string>
<string name="healthchat_field_specialty_placeholder">p. ex. médecine générale</string>
<string name="healthchat_field_practice_name_placeholder">p. ex. Praxis Muster</string>
<string name="healthchat_field_city">Ville</string>
<string name="healthchat_field_city_placeholder">p. ex. Zurich</string>
<string name="healthchat_field_business_email_placeholder">cabinet@exemple.ch</string>
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
<string name="healthchat_field_street">Rue</string>
<string name="healthchat_field_house_number">N°</string>
<string name="healthchat_field_postal_code">NPA</string>
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
<string name="healthchat_screen_edit_profile_title">Modifier le profil</string>
<string name="healthchat_screen_edit_profile_section_personal">Informations personnelles</string>
<string name="healthchat_screen_edit_profile_section_professional">Informations professionnelles</string>
<string name="healthchat_screen_edit_profile_section_contact">Coordonnées</string>
<string name="healthchat_screen_edit_profile_section_address">Adresse du cabinet</string>
<string name="healthchat_screen_edit_profile_saving">Enregistrement du profil…</string>
<string name="healthchat_screen_edit_profile_error_title">Impossible d’enregistrer le profil</string>
<string name="healthchat_screen_edit_profile_load_error">Le profil n’a pas pu être chargé.</string>
<string name="healthchat_screen_edit_profile_recommended">Recommandé pour un profil complet</string>
<string name="healthchat_error_field_required">Ce champ est obligatoire</string>
<string name="healthchat_error_invalid_email">Veuillez saisir une adresse e-mail valide</string>
<string name="healthchat_error_invalid_url">Veuillez saisir une URL valide (p. ex. https://praxis-muster.ch)</string>
<string name="healthchat_error_note_too_short">Le message personnel doit contenir au moins 10 caractères</string>
<string name="healthchat_error_avatar_too_large">L’image ne doit pas dépasser 2 Mo.</string>
<string name="healthchat_banner_incomplete_title">Profil incomplet</string>
<string name="healthchat_banner_incomplete_description">Veuillez compléter votre profil pour que les autres membres puissent vous trouver. Informations manquantes : %1$s</string>
<string name="healthchat_banner_incomplete_action">Compléter le profil</string>
<string name="healthchat_screen_invite_title">Inviter un membre</string>
<string name="healthchat_screen_invite_description">Invitez de nouveaux membres sur HealthChat en saisissant leur adresse e-mail et un message personnel.</string>
<string name="healthchat_screen_invite_email_label">Adresse e-mail</string>
<string name="healthchat_screen_invite_email_placeholder">exemple@domaine.ch</string>
<string name="healthchat_screen_invite_note_label">Message personnel</string>
<string name="healthchat_screen_invite_note_placeholder">Écrivez un message personnel au nouveau membre…</string>
<string name="healthchat_screen_invite_note_counter">%1$d/10 caractères</string>
<string name="healthchat_screen_invite_send">Envoyer l’invitation</string>
<string name="healthchat_screen_invite_sending">Envoi de l’invitation…</string>
</resources>
@@ -38,4 +38,45 @@
<string name="healthchat_a11y_refresh">Aggiorna</string>
<string name="healthchat_a11y_edit_profile">Modifica profilo</string>
<string name="healthchat_a11y_invite_member">Invita un nuovo membro</string>
<string name="healthchat_field_name">Nome visualizzato</string>
<string name="healthchat_field_title">Titolo</string>
<string name="healthchat_field_title_placeholder">ad es. Dr. med.</string>
<string name="healthchat_field_first_name">Nome</string>
<string name="healthchat_field_last_name">Cognome</string>
<string name="healthchat_field_specialty_placeholder">ad es. medicina generale</string>
<string name="healthchat_field_practice_name_placeholder">ad es. Praxis Muster</string>
<string name="healthchat_field_city">Città</string>
<string name="healthchat_field_city_placeholder">ad es. Zurigo</string>
<string name="healthchat_field_business_email_placeholder">studio@esempio.ch</string>
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
<string name="healthchat_field_street">Via</string>
<string name="healthchat_field_house_number">N.</string>
<string name="healthchat_field_postal_code">CAP</string>
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
<string name="healthchat_screen_edit_profile_title">Modifica profilo</string>
<string name="healthchat_screen_edit_profile_section_personal">Dati personali</string>
<string name="healthchat_screen_edit_profile_section_professional">Dati professionali</string>
<string name="healthchat_screen_edit_profile_section_contact">Contatti</string>
<string name="healthchat_screen_edit_profile_section_address">Indirizzo dello studio</string>
<string name="healthchat_screen_edit_profile_saving">Salvataggio del profilo…</string>
<string name="healthchat_screen_edit_profile_error_title">Impossibile salvare il profilo</string>
<string name="healthchat_screen_edit_profile_load_error">Impossibile caricare il profilo.</string>
<string name="healthchat_screen_edit_profile_recommended">Consigliato per un profilo completo</string>
<string name="healthchat_error_field_required">Questo campo è obbligatorio</string>
<string name="healthchat_error_invalid_email">Inserisci un indirizzo e-mail valido</string>
<string name="healthchat_error_invalid_url">Inserisci un URL valido (ad es. https://praxis-muster.ch)</string>
<string name="healthchat_error_note_too_short">Il messaggio personale deve contenere almeno 10 caratteri</string>
<string name="healthchat_error_avatar_too_large">L’immagine non deve superare i 2 MB.</string>
<string name="healthchat_banner_incomplete_title">Profilo incompleto</string>
<string name="healthchat_banner_incomplete_description">Completa il tuo profilo affinché gli altri membri possano trovarti. Dati mancanti: %1$s</string>
<string name="healthchat_banner_incomplete_action">Completa il profilo</string>
<string name="healthchat_screen_invite_title">Invita un membro</string>
<string name="healthchat_screen_invite_description">Invita nuovi membri su HealthChat inserendo il loro indirizzo e-mail e un messaggio personale.</string>
<string name="healthchat_screen_invite_email_label">Indirizzo e-mail</string>
<string name="healthchat_screen_invite_email_placeholder">esempio@dominio.ch</string>
<string name="healthchat_screen_invite_note_label">Messaggio personale</string>
<string name="healthchat_screen_invite_note_placeholder">Scrivi un messaggio personale al nuovo membro…</string>
<string name="healthchat_screen_invite_note_counter">%1$d/10 caratteri</string>
<string name="healthchat_screen_invite_send">Invia invito</string>
<string name="healthchat_screen_invite_sending">Invio dell’invito…</string>
</resources>
@@ -38,4 +38,45 @@
<string name="healthchat_a11y_refresh">Refresh</string>
<string name="healthchat_a11y_edit_profile">Edit profile</string>
<string name="healthchat_a11y_invite_member">Invite a new member</string>
<string name="healthchat_field_name">Display name</string>
<string name="healthchat_field_title">Title</string>
<string name="healthchat_field_title_placeholder">e.g. Dr. med.</string>
<string name="healthchat_field_first_name">First name</string>
<string name="healthchat_field_last_name">Last name</string>
<string name="healthchat_field_specialty_placeholder">e.g. general medicine</string>
<string name="healthchat_field_practice_name_placeholder">e.g. Praxis Muster</string>
<string name="healthchat_field_city">City</string>
<string name="healthchat_field_city_placeholder">e.g. Zurich</string>
<string name="healthchat_field_business_email_placeholder">praxis@example.ch</string>
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
<string name="healthchat_field_street">Street</string>
<string name="healthchat_field_house_number">No.</string>
<string name="healthchat_field_postal_code">Postal code</string>
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
<string name="healthchat_screen_edit_profile_title">Edit profile</string>
<string name="healthchat_screen_edit_profile_section_personal">Personal details</string>
<string name="healthchat_screen_edit_profile_section_professional">Professional details</string>
<string name="healthchat_screen_edit_profile_section_contact">Contact details</string>
<string name="healthchat_screen_edit_profile_section_address">Practice address</string>
<string name="healthchat_screen_edit_profile_saving">Saving profile…</string>
<string name="healthchat_screen_edit_profile_error_title">Couldn’t save the profile</string>
<string name="healthchat_screen_edit_profile_load_error">The profile couldn’t be loaded.</string>
<string name="healthchat_screen_edit_profile_recommended">Recommended for a complete profile</string>
<string name="healthchat_error_field_required">This field is required</string>
<string name="healthchat_error_invalid_email">Please enter a valid email address</string>
<string name="healthchat_error_invalid_url">Please enter a valid URL (e.g. https://praxis-muster.ch)</string>
<string name="healthchat_error_note_too_short">The personal message must be at least 10 characters long</string>
<string name="healthchat_error_avatar_too_large">The image must not exceed 2 MB.</string>
<string name="healthchat_banner_incomplete_title">Profile incomplete</string>
<string name="healthchat_banner_incomplete_description">Please complete your profile so other members can find you. Missing details: %1$s</string>
<string name="healthchat_banner_incomplete_action">Complete profile</string>
<string name="healthchat_screen_invite_title">Invite a member</string>
<string name="healthchat_screen_invite_description">Invite new members to HealthChat by entering their email address and a personal message.</string>
<string name="healthchat_screen_invite_email_label">Email address</string>
<string name="healthchat_screen_invite_email_placeholder">example@domain.ch</string>
<string name="healthchat_screen_invite_note_label">Personal message</string>
<string name="healthchat_screen_invite_note_placeholder">Write a personal message to the new member…</string>
<string name="healthchat_screen_invite_note_counter">%1$d/10 characters</string>
<string name="healthchat_screen_invite_send">Send invitation</string>
<string name="healthchat_screen_invite_sending">Sending invitation…</string>
</resources>
@@ -13,6 +13,7 @@ import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
@@ -58,7 +59,11 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.network)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings)
@@ -67,6 +72,9 @@ dependencies {
testImplementation(projects.features.healthchat.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.api)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultHealthChatEntryPoint : HealthChatEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: HealthChatEntryPoint.Params,
callback: HealthChatEntryPoint.Callback,
): Node {
return parentNode.createNode<HealthChatFlowNode>(buildContext, plugins = listOf(params, callback))
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.HealthChatEntryPoint
import io.element.android.features.healthchat.impl.invite.HealthChatInviteNode
import io.element.android.features.healthchat.impl.profile.edit.EditHealthChatProfileNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class HealthChatFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<HealthChatFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins
.filterIsInstance<HealthChatEntryPoint.Params>()
.first()
.initialTarget
.toNavTarget(),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object EditOwnProfile : NavTarget
@Parcelize
data object Invite : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
// The HealthChatEntryPoint.Callback plugin is forwarded so the child screens can
// close the whole flow.
return when (navTarget) {
NavTarget.EditOwnProfile -> createNode<EditHealthChatProfileNode>(buildContext, plugins = plugins)
NavTarget.Invite -> createNode<HealthChatInviteNode>(buildContext, plugins = plugins)
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
private fun HealthChatEntryPoint.InitialTarget.toNavTarget(): HealthChatFlowNode.NavTarget {
return when (this) {
HealthChatEntryPoint.InitialTarget.EditOwnProfile -> HealthChatFlowNode.NavTarget.EditOwnProfile
HealthChatEntryPoint.InitialTarget.Invite -> HealthChatFlowNode.NavTarget.Invite
}
}
@@ -8,19 +8,25 @@
package io.element.android.features.healthchat.impl.directory
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.impl.status.HealthChatAccountStatusPresenter
import io.element.android.features.healthchat.impl.status.HealthChatAccountStatusView
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(SessionScope::class)
class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer {
class DefaultMemberDirectoryRenderer(
private val accountStatusPresenter: HealthChatAccountStatusPresenter,
) : MemberDirectoryRenderer {
@Composable
override fun Render(
state: MemberDirectoryState,
@@ -33,16 +39,26 @@ class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer {
onInviteClick: (() -> Unit)?,
modifier: Modifier,
) {
MemberDirectoryView(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = onOpenDm,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
modifier = modifier,
)
Column(modifier = modifier.fillMaxSize()) {
// The "profile incomplete" advisory; the pending-approval case is excluded because
// the directory itself shows the verbatim server pending message.
if (onEditProfileClick != null) {
HealthChatAccountStatusView(
state = accountStatusPresenter.present(),
onCompleteProfileClick = onEditProfileClick,
includePendingApproval = false,
)
}
MemberDirectoryView(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
onOpenDm = onOpenDm,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
)
}
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
sealed interface HealthChatInviteEvent {
data class UpdateEmail(val email: String) : HealthChatInviteEvent
data class UpdatePersonalNote(val personalNote: String) : HealthChatInviteEvent
data object Send : HealthChatInviteEvent
data object CloseDialog : HealthChatInviteEvent
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.HealthChatEntryPoint
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class HealthChatInviteNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: HealthChatInvitePresenter,
) : Node(buildContext, plugins = plugins) {
private val callback: HealthChatEntryPoint.Callback = callback()
@Composable
override fun View(modifier: Modifier) {
HealthChatInviteView(
state = presenter.present(),
onBackClick = callback::onDone,
onInviteSuccess = callback::onDone,
modifier = modifier,
)
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatInviteService
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.impl.profile.HealthChatProfileValidator
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
private const val MIN_NOTE_LENGTH = 10
@Inject
class HealthChatInvitePresenter(
private val inviteService: HealthChatInviteService,
) : Presenter<HealthChatInviteState> {
@Composable
override fun present(): HealthChatInviteState {
val coroutineScope = rememberCoroutineScope()
var email by rememberSaveable { mutableStateOf("") }
var personalNote by rememberSaveable { mutableStateOf("") }
var emailError by remember { mutableStateOf<HealthChatFieldError?>(null) }
var personalNoteError by remember { mutableStateOf<HealthChatFieldError?>(null) }
val sendAction = remember { mutableStateOf<AsyncAction<String>>(AsyncAction.Uninitialized) }
val canSubmit = email.isNotBlank() &&
personalNote.trim().length >= MIN_NOTE_LENGTH &&
sendAction.value !is AsyncAction.Loading
fun validate(): Boolean {
emailError = when {
email.isBlank() -> HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_field_required)
!HealthChatProfileValidator.isValidEmail(email) ->
HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_invalid_email)
else -> null
}
personalNoteError = when {
personalNote.trim().length < MIN_NOTE_LENGTH ->
HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_note_too_short)
else -> null
}
return emailError == null && personalNoteError == null
}
fun handleEvents(event: HealthChatInviteEvent) {
when (event) {
is HealthChatInviteEvent.UpdateEmail -> {
email = event.email
emailError = null
}
is HealthChatInviteEvent.UpdatePersonalNote -> {
personalNote = event.personalNote
personalNoteError = null
}
HealthChatInviteEvent.Send -> {
if (!validate()) return
coroutineScope.launch {
sendAction.value = AsyncAction.Loading
inviteService.invite(email = email.trim(), personalNote = personalNote.trim()).fold(
onSuccess = { message -> sendAction.value = AsyncAction.Success(message) },
onFailure = { error ->
if (error is HealthChatApiException.Validation) {
// Server-side field errors surface under their fields, verbatim.
error.fieldErrors["email"]?.let {
emailError = HealthChatFieldError.Server(it.joinToString("\n"))
}
error.fieldErrors["personalnote"]?.let {
personalNoteError = HealthChatFieldError.Server(it.joinToString("\n"))
}
val unmappedErrors = error.fieldErrors.keys - setOf("email", "personalnote")
if (unmappedErrors.isNotEmpty()) {
sendAction.value = AsyncAction.Failure(error)
} else {
sendAction.value = AsyncAction.Uninitialized
}
} else {
sendAction.value = AsyncAction.Failure(error)
}
},
)
}
}
HealthChatInviteEvent.CloseDialog -> sendAction.value = AsyncAction.Uninitialized
}
}
return HealthChatInviteState(
email = email,
personalNote = personalNote,
emailError = emailError,
personalNoteError = personalNoteError,
canSubmit = canSubmit,
sendAction = sendAction.value,
eventSink = ::handleEvents,
)
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
import androidx.annotation.StringRes
import io.element.android.libraries.architecture.AsyncAction
/** A form field error: either a localized client-side message or a verbatim server message. */
sealed interface HealthChatFieldError {
data class Localised(@StringRes val res: Int) : HealthChatFieldError
data class Server(val message: String) : HealthChatFieldError
}
data class HealthChatInviteState(
val email: String,
val personalNote: String,
val emailError: HealthChatFieldError?,
val personalNoteError: HealthChatFieldError?,
val canSubmit: Boolean,
/** The success value carries the verbatim German server confirmation. */
val sendAction: AsyncAction<String>,
val eventSink: (HealthChatInviteEvent) -> Unit,
)
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.libraries.architecture.AsyncAction
open class HealthChatInviteStateProvider : PreviewParameterProvider<HealthChatInviteState> {
override val values: Sequence<HealthChatInviteState>
get() = sequenceOf(
aHealthChatInviteState(),
aHealthChatInviteState(
email = "neu@beispiel.ch",
personalNote = "Willkommen bei HealthChat! Ich lade Sie gerne ein.",
canSubmit = true,
),
aHealthChatInviteState(
email = "keine-email",
emailError = HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_invalid_email),
personalNote = "kurz",
personalNoteError = HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_note_too_short),
),
aHealthChatInviteState(
email = "neu@beispiel.ch",
personalNote = "Willkommen bei HealthChat! Ich lade Sie gerne ein.",
personalNoteError = HealthChatFieldError.Server("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"),
),
aHealthChatInviteState(
email = "neu@beispiel.ch",
personalNote = "Willkommen bei HealthChat! Ich lade Sie gerne ein.",
sendAction = AsyncAction.Loading,
),
)
}
fun aHealthChatInviteState(
email: String = "",
personalNote: String = "",
emailError: HealthChatFieldError? = null,
personalNoteError: HealthChatFieldError? = null,
canSubmit: Boolean = false,
sendAction: AsyncAction<String> = AsyncAction.Uninitialized,
eventSink: (HealthChatInviteEvent) -> Unit = {},
) = HealthChatInviteState(
email = email,
personalNote = personalNote,
emailError = emailError,
personalNoteError = personalNoteError,
canSubmit = canSubmit,
sendAction = sendAction,
eventSink = eventSink,
)
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.impl.healthChatUserMessage
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthChatInviteView(
state: HealthChatInviteState,
onBackClick: () -> Unit,
onInviteSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
TopAppBar(
titleStr = stringResource(HealthChatStrings.healthchat_screen_invite_title),
navigationIcon = { BackButton(onBackClick) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(HealthChatStrings.healthchat_screen_invite_description),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(24.dp))
TextField(
value = state.email,
onValueChange = { state.eventSink(HealthChatInviteEvent.UpdateEmail(it)) },
label = stringResource(HealthChatStrings.healthchat_screen_invite_email_label),
placeholder = stringResource(HealthChatStrings.healthchat_screen_invite_email_placeholder),
supportingText = state.emailError?.text(),
validity = if (state.emailError != null) TextFieldValidity.Invalid else TextFieldValidity.None,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
TextField(
value = state.personalNote,
onValueChange = { state.eventSink(HealthChatInviteEvent.UpdatePersonalNote(it)) },
label = stringResource(HealthChatStrings.healthchat_screen_invite_note_label),
placeholder = stringResource(HealthChatStrings.healthchat_screen_invite_note_placeholder),
supportingText = state.personalNoteError?.text()
?: stringResource(HealthChatStrings.healthchat_screen_invite_note_counter, state.personalNote.trim().length),
validity = if (state.personalNoteError != null) TextFieldValidity.Invalid else TextFieldValidity.None,
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Button(
text = stringResource(HealthChatStrings.healthchat_screen_invite_send),
onClick = {
focusManager.clearFocus()
state.eventSink(HealthChatInviteEvent.Send)
},
enabled = state.canSubmit,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
}
AsyncActionView(
async = state.sendAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(HealthChatStrings.healthchat_screen_invite_sending),
)
},
onSuccess = { onInviteSuccess() },
errorMessage = { error -> error.healthChatUserMessage() },
onErrorDismiss = { state.eventSink(HealthChatInviteEvent.CloseDialog) },
)
}
}
@Composable
private fun HealthChatFieldError.text(): String {
return when (this) {
is HealthChatFieldError.Localised -> stringResource(res)
is HealthChatFieldError.Server -> message
}
}
@PreviewsDayNight
@Composable
internal fun HealthChatInviteViewPreview(
@PreviewParameter(HealthChatInviteStateProvider::class) state: HealthChatInviteState
) = ElementPreview {
HealthChatInviteView(
state = state,
onBackClick = {},
onInviteSuccess = {},
)
}
@@ -0,0 +1,242 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile
import androidx.annotation.StringRes
import androidx.compose.ui.text.input.KeyboardType
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.features.healthchat.api.HealthChatStrings
enum class HealthChatFieldRequirement {
/** Blank blocks saving. */
Required,
/** Never blocks saving, but the server reports the profile as incomplete without it. */
RecommendedForCompleteness,
Optional,
}
/**
* Descriptor of the 13 editable HealthChat profile fields (order = form order).
*/
enum class HealthChatProfileField(
val apiKey: String,
@StringRes val labelRes: Int,
@StringRes val placeholderRes: Int?,
val maxLength: Int,
val requirement: HealthChatFieldRequirement,
val keyboardType: KeyboardType,
val minLines: Int = 1,
) {
Title(
apiKey = "title",
labelRes = HealthChatStrings.healthchat_field_title,
placeholderRes = HealthChatStrings.healthchat_field_title_placeholder,
maxLength = 50,
requirement = HealthChatFieldRequirement.Optional,
keyboardType = KeyboardType.Text,
),
FirstName(
apiKey = "firstName",
labelRes = HealthChatStrings.healthchat_field_first_name,
placeholderRes = null,
maxLength = 100,
requirement = HealthChatFieldRequirement.Required,
keyboardType = KeyboardType.Text,
),
LastName(
apiKey = "lastName",
labelRes = HealthChatStrings.healthchat_field_last_name,
placeholderRes = null,
maxLength = 100,
requirement = HealthChatFieldRequirement.Required,
keyboardType = KeyboardType.Text,
),
Specialty(
apiKey = "specialty",
labelRes = HealthChatStrings.healthchat_field_specialty,
placeholderRes = HealthChatStrings.healthchat_field_specialty_placeholder,
maxLength = 200,
requirement = HealthChatFieldRequirement.Required,
keyboardType = KeyboardType.Text,
),
PracticeName(
apiKey = "practiceName",
labelRes = HealthChatStrings.healthchat_field_practice_name,
placeholderRes = HealthChatStrings.healthchat_field_practice_name_placeholder,
maxLength = 200,
requirement = HealthChatFieldRequirement.Required,
keyboardType = KeyboardType.Text,
),
BusinessCity(
apiKey = "businessCity",
labelRes = HealthChatStrings.healthchat_field_city,
placeholderRes = HealthChatStrings.healthchat_field_city_placeholder,
maxLength = 100,
requirement = HealthChatFieldRequirement.Required,
keyboardType = KeyboardType.Text,
),
FurtherInfo(
apiKey = "furtherInfo",
labelRes = HealthChatStrings.healthchat_field_further_info,
placeholderRes = null,
maxLength = 1000,
requirement = HealthChatFieldRequirement.Optional,
keyboardType = KeyboardType.Text,
minLines = 3,
),
BusinessEmail(
apiKey = "businessEmail",
labelRes = HealthChatStrings.healthchat_field_business_email,
placeholderRes = HealthChatStrings.healthchat_field_business_email_placeholder,
maxLength = 200,
requirement = HealthChatFieldRequirement.RecommendedForCompleteness,
keyboardType = KeyboardType.Email,
),
BusinessTel(
apiKey = "businessTel",
labelRes = HealthChatStrings.healthchat_field_business_tel,
placeholderRes = HealthChatStrings.healthchat_field_business_tel_placeholder,
maxLength = 50,
requirement = HealthChatFieldRequirement.RecommendedForCompleteness,
keyboardType = KeyboardType.Phone,
),
Street(
apiKey = "street",
labelRes = HealthChatStrings.healthchat_field_street,
placeholderRes = null,
maxLength = 200,
requirement = HealthChatFieldRequirement.Optional,
keyboardType = KeyboardType.Text,
),
HouseNumber(
apiKey = "houseNumber",
labelRes = HealthChatStrings.healthchat_field_house_number,
placeholderRes = null,
maxLength = 20,
requirement = HealthChatFieldRequirement.Optional,
keyboardType = KeyboardType.Text,
),
PostalCode(
apiKey = "postalCode",
labelRes = HealthChatStrings.healthchat_field_postal_code,
placeholderRes = null,
maxLength = 20,
requirement = HealthChatFieldRequirement.Optional,
keyboardType = KeyboardType.Text,
),
Website(
apiKey = "website",
labelRes = HealthChatStrings.healthchat_field_website,
placeholderRes = HealthChatStrings.healthchat_field_website_placeholder,
maxLength = 500,
requirement = HealthChatFieldRequirement.Optional,
keyboardType = KeyboardType.Uri,
),
;
companion object {
fun fromApiKey(apiKey: String): HealthChatProfileField? {
return entries.firstOrNull { it.apiKey.equals(apiKey, ignoreCase = true) }
}
}
}
/**
* Form sections in display order (mirrors the web form).
*/
enum class HealthChatProfileFormSection(
@StringRes val titleRes: Int,
val fields: List<HealthChatProfileField>,
) {
Personal(
titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_personal,
fields = listOf(
HealthChatProfileField.Title,
HealthChatProfileField.FirstName,
HealthChatProfileField.LastName,
),
),
Professional(
titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_professional,
fields = listOf(
HealthChatProfileField.Specialty,
HealthChatProfileField.PracticeName,
HealthChatProfileField.BusinessCity,
HealthChatProfileField.FurtherInfo,
),
),
Contact(
titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_contact,
fields = listOf(
HealthChatProfileField.BusinessEmail,
HealthChatProfileField.BusinessTel,
),
),
Address(
titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_address,
fields = listOf(
HealthChatProfileField.Street,
HealthChatProfileField.HouseNumber,
HealthChatProfileField.PostalCode,
HealthChatProfileField.Website,
),
),
}
/** The current server-side value of a field, or null when unset. */
fun HealthChatMember.valueOf(field: HealthChatProfileField): String? {
return when (field) {
HealthChatProfileField.Title -> title
HealthChatProfileField.FirstName -> firstName
HealthChatProfileField.LastName -> lastName
HealthChatProfileField.Specialty -> specialty
HealthChatProfileField.PracticeName -> practiceName
HealthChatProfileField.BusinessCity -> businessCity
HealthChatProfileField.FurtherInfo -> furtherInfo
HealthChatProfileField.BusinessEmail -> businessEmail
HealthChatProfileField.BusinessTel -> businessTel
HealthChatProfileField.Street -> street
HealthChatProfileField.HouseNumber -> houseNumber
HealthChatProfileField.PostalCode -> postalCode
HealthChatProfileField.Website -> website
}
}
/**
* Diff-only update: unchanged fields stay null (omitted from the PUT body), changed fields
* carry their trimmed new value, cleared fields carry "".
*/
fun buildProfileUpdate(
baseline: HealthChatMember,
values: Map<HealthChatProfileField, String>,
): HealthChatProfileUpdate {
fun diff(field: HealthChatProfileField): String? {
val newValue = values[field].orEmpty().trim()
val oldValue = baseline.valueOf(field).orEmpty().trim()
return newValue.takeIf { it != oldValue }
}
return HealthChatProfileUpdate(
title = diff(HealthChatProfileField.Title),
firstName = diff(HealthChatProfileField.FirstName),
lastName = diff(HealthChatProfileField.LastName),
specialty = diff(HealthChatProfileField.Specialty),
practiceName = diff(HealthChatProfileField.PracticeName),
businessCity = diff(HealthChatProfileField.BusinessCity),
furtherInfo = diff(HealthChatProfileField.FurtherInfo),
businessEmail = diff(HealthChatProfileField.BusinessEmail),
businessTel = diff(HealthChatProfileField.BusinessTel),
street = diff(HealthChatProfileField.Street),
houseNumber = diff(HealthChatProfileField.HouseNumber),
postalCode = diff(HealthChatProfileField.PostalCode),
website = diff(HealthChatProfileField.Website),
)
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile
import androidx.annotation.StringRes
import io.element.android.features.healthchat.api.HealthChatStrings
/**
* Client-side validation of the profile form. Pure Kotlin (no `android.util.Patterns`,
* which is null in JVM unit tests). The server stays authoritative.
*/
object HealthChatProfileValidator {
val EMAIL_REGEX = Regex("""^[^\s@]+@[^\s@]+\.[^\s@]{2,}$""")
val URL_REGEX = Regex("""^https?://[^\s/$.?#].\S*$""", RegexOption.IGNORE_CASE)
/**
* @return client-side errors per field; empty when the form can be saved.
* Email/tel are deliberately NOT save blockers (web parity — don't trap someone who
* only wants to fix a typo); only format checks and the 5 required fields block.
*/
fun validate(values: Map<HealthChatProfileField, String>): Map<HealthChatProfileField, Int> {
val errors = mutableMapOf<HealthChatProfileField, Int>()
for (field in HealthChatProfileField.entries) {
val value = values[field].orEmpty().trim()
val error = validateField(field, value)
if (error != null) {
errors[field] = error
}
}
return errors
}
@StringRes
private fun validateField(field: HealthChatProfileField, trimmedValue: String): Int? {
if (trimmedValue.isEmpty()) {
return if (field.requirement == HealthChatFieldRequirement.Required) {
HealthChatStrings.healthchat_error_field_required
} else {
null
}
}
return when (field) {
HealthChatProfileField.BusinessEmail ->
HealthChatStrings.healthchat_error_invalid_email.takeIf { !EMAIL_REGEX.matches(trimmedValue) }
HealthChatProfileField.Website ->
HealthChatStrings.healthchat_error_invalid_url.takeIf { !URL_REGEX.matches(trimmedValue) }
else -> null
}
}
fun isValidEmail(value: String): Boolean = EMAIL_REGEX.matches(value.trim())
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import io.element.android.features.healthchat.impl.profile.HealthChatProfileField
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface EditHealthChatProfileEvent {
data class UpdateField(val field: HealthChatProfileField, val value: String) : EditHealthChatProfileEvent
data class HandleAvatarAction(val action: AvatarAction) : EditHealthChatProfileEvent
data object Save : EditHealthChatProfileEvent
data object RetryLoad : EditHealthChatProfileEvent
data object Exit : EditHealthChatProfileEvent
data object CloseDialog : EditHealthChatProfileEvent
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
interface EditHealthChatProfileNavigator {
fun close()
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.HealthChatEntryPoint
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class EditHealthChatProfileNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: EditHealthChatProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins), EditHealthChatProfileNavigator {
private val callback: HealthChatEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(navigator = this)
override fun close() {
callback.onDone()
}
@Composable
override fun View(modifier: Modifier) {
EditHealthChatProfileView(
state = presenter.present(),
onEditProfileSuccess = ::close,
modifier = modifier,
)
}
}
@@ -0,0 +1,302 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.impl.profile.HealthChatProfileField
import io.element.android.features.healthchat.impl.profile.HealthChatProfileValidator
import io.element.android.features.healthchat.impl.profile.buildProfileUpdate
import io.element.android.features.healthchat.impl.profile.valueOf
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
@AssistedInject
class EditHealthChatProfilePresenter(
@Assisted private val navigator: EditHealthChatProfileNavigator,
private val profileRepository: HealthChatProfileRepository,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
private val temporaryUriDeleter: TemporaryUriDeleter,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<EditHealthChatProfileState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@AssistedFactory
interface Factory {
fun create(navigator: EditHealthChatProfileNavigator): EditHealthChatProfilePresenter
}
@Composable
override fun present(): EditHealthChatProfileState {
val cameraPermissionState = cameraPermissionPresenter.present()
val coroutineScope = rememberCoroutineScope()
var loadTrigger by rememberSaveable { mutableIntStateOf(0) }
var loadState by remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
var baseline by remember { mutableStateOf<HealthChatMember?>(null) }
var values by remember { mutableStateOf(persistentMapOf<HealthChatProfileField, String>()) }
var fieldErrors by remember { mutableStateOf(persistentMapOf<HealthChatProfileField, Int>()) }
var serverFieldErrors by remember { mutableStateOf(persistentMapOf<HealthChatProfileField, String>()) }
var avatarUri by remember { mutableStateOf<String?>(null) }
var completenessMessage by remember { mutableStateOf<String?>(null) }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
LaunchedEffect(loadTrigger) {
loadState = AsyncData.Loading()
profileRepository.refreshOwnProfile().fold(
onSuccess = { member ->
baseline = member
values = HealthChatProfileField.entries
.associateWith { field -> member.valueOf(field).orEmpty() }
.let { persistentMapOf<HealthChatProfileField, String>().putAll(it) }
avatarUri = member.avatarUrl
loadState = AsyncData.Success(Unit)
},
onFailure = { loadState = AsyncData.Failure(it) },
)
// Advisory only — a failure here must never block the editor.
profileRepository.refreshCompleteness().onSuccess { completeness ->
completenessMessage = completeness.message.takeIf { !completeness.isComplete }
}
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri ->
if (uri != null) {
temporaryUriDeleter.delete(avatarUri.takeIf { it?.startsWith("content:") == true }?.toUri())
avatarUri = uri.toString()
}
}
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri ->
if (uri != null) {
temporaryUriDeleter.delete(avatarUri.takeIf { it?.startsWith("content:") == true }?.toUri())
avatarUri = uri.toString()
}
}
)
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
cameraPhotoPicker.launch()
}
}
val avatarActions by remember(avatarUri) {
derivedStateOf {
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { avatarUri != null },
).toImmutableList()
}
}
val hasChanges by remember {
derivedStateOf {
val currentBaseline = baseline
currentBaseline != null &&
(
!buildProfileUpdate(currentBaseline, values).isEmpty() ||
avatarUri != currentBaseline.avatarUrl
)
}
}
fun handleEvent(event: EditHealthChatProfileEvent) {
when (event) {
is EditHealthChatProfileEvent.UpdateField -> {
values = values.put(event.field, event.value.take(event.field.maxLength))
fieldErrors = fieldErrors.remove(event.field)
serverFieldErrors = serverFieldErrors.remove(event.field)
}
is EditHealthChatProfileEvent.HandleAvatarAction -> when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
AvatarAction.Remove -> {
temporaryUriDeleter.delete(avatarUri.takeIf { it?.startsWith("content:") == true }?.toUri())
avatarUri = null
}
}
EditHealthChatProfileEvent.Save -> {
val currentBaseline = baseline ?: return
val errors = HealthChatProfileValidator.validate(values)
if (errors.isNotEmpty()) {
fieldErrors = persistentMapOf<HealthChatProfileField, Int>().putAll(errors)
return
}
coroutineScope.saveChanges(
baseline = currentBaseline,
values = values,
avatarUri = avatarUri,
action = saveAction,
onValidationError = { validation ->
serverFieldErrors = persistentMapOf<HealthChatProfileField, String>().putAll(
validation.fieldErrors.entries
.mapNotNull { (key, messages) ->
HealthChatProfileField.fromApiKey(key)?.let { field ->
field to messages.joinToString("\n")
}
}
.toMap()
)
},
)
}
EditHealthChatProfileEvent.RetryLoad -> loadTrigger++
EditHealthChatProfileEvent.Exit -> when (saveAction.value) {
is AsyncAction.Confirming -> {
saveAction.value = AsyncAction.Uninitialized
navigator.close()
}
AsyncAction.Loading -> Unit
is AsyncAction.Failure,
is AsyncAction.Success -> Unit
AsyncAction.Uninitialized -> {
if (hasChanges) {
saveAction.value = AsyncAction.ConfirmingCancellation
} else {
navigator.close()
}
}
}
EditHealthChatProfileEvent.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
}
}
return EditHealthChatProfileState(
userId = matrixClient.sessionId,
loadState = loadState,
values = values.toImmutableMap(),
fieldErrors = fieldErrors.toImmutableMap(),
serverFieldErrors = serverFieldErrors.toImmutableMap(),
displayName = baseline?.displayName,
avatarUrl = avatarUri,
avatarActions = avatarActions,
completenessMessage = completenessMessage,
saveButtonEnabled = hasChanges && saveAction.value !is AsyncAction.Loading,
saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.saveChanges(
baseline: HealthChatMember,
values: Map<HealthChatProfileField, String>,
avatarUri: String?,
action: MutableState<AsyncAction<Unit>>,
onValidationError: (HealthChatApiException.Validation) -> Unit,
) = launch {
action.value = AsyncAction.Loading
try {
// Fields first: the avatar is the likelier failure, and on avatar failure the
// dismissed error dialog re-loads the profile so the form shows reality.
val update = buildProfileUpdate(baseline, values)
if (!update.isEmpty()) {
profileRepository.updateOwnProfile(update).getOrThrow()
}
if (avatarUri != baseline.avatarUrl) {
if (avatarUri != null) {
uploadAvatar(avatarUri.toUri()).getOrThrow()
} else {
profileRepository.deleteAvatar().getOrThrow()
}
}
// Pull the server-derived display name into MatrixClient.userProfile. Never call
// setDisplayName() from the client — the server derives it and syncs Zitadel.
matrixClient.getUserProfile()
profileRepository.refreshCompleteness()
action.value = AsyncAction.Success(Unit)
} catch (exception: CancellationException) {
throw exception
} catch (exception: HealthChatApiException.Validation) {
onValidationError(exception)
action.value = AsyncAction.Uninitialized
} catch (exception: Exception) {
Timber.e(exception, "Failed to save the HealthChat profile")
action.value = AsyncAction.Failure(exception)
}
}
private suspend fun uploadAvatar(avatarUri: Uri): Result<String> {
val preprocessed = mediaPreProcessor.process(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
// Deliberately NOT the user's optimization preference: a disabled optimization
// setting would push a full-size original into a 2 MiB endpoint.
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
).getOrThrow()
val bytes = preprocessed.file.readBytes()
if (bytes.size > MAX_AVATAR_SIZE_BYTES) {
throw HealthChatAvatarTooLargeException()
}
return profileRepository.uploadAvatar(
fileName = "avatar.jpg",
mimeType = MimeTypes.Jpeg,
data = bytes,
)
}
companion object {
// Server-enforced limit: 2 MB.
const val MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024
}
}
/** The processed avatar still exceeds the server's 2 MiB limit; fails before any network call. */
class HealthChatAvatarTooLargeException : Exception("Avatar exceeds the 2 MiB limit")
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import io.element.android.features.healthchat.impl.profile.HealthChatProfileField
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
data class EditHealthChatProfileState(
val userId: UserId,
/**
* The initial profile GET. A full-screen error with retry: without a loaded baseline the
* diff-only save cannot be computed (a blank form would clear server fields).
*/
val loadState: AsyncData<Unit>,
val values: ImmutableMap<HealthChatProfileField, String>,
/** Client-side validation errors (string resources). */
val fieldErrors: ImmutableMap<HealthChatProfileField, Int>,
/** Verbatim server-side validation errors, matched by api key. */
val serverFieldErrors: ImmutableMap<HealthChatProfileField, String>,
val displayName: String?,
val avatarUrl: String?,
val avatarActions: ImmutableList<AvatarAction>,
/** Verbatim server advisory when the profile is incomplete; never blocks saving. */
val completenessMessage: String?,
val saveButtonEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
val cameraPermissionState: PermissionsState,
val eventSink: (EditHealthChatProfileEvent) -> Unit,
)
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.impl.profile.HealthChatProfileField
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
open class EditHealthChatProfileStateProvider : PreviewParameterProvider<EditHealthChatProfileState> {
override val values: Sequence<EditHealthChatProfileState>
get() = sequenceOf(
anEditHealthChatProfileState(),
anEditHealthChatProfileState(loadState = AsyncData.Loading()),
anEditHealthChatProfileState(
loadState = AsyncData.Failure(HealthChatApiException.Network(RuntimeException("no network"))),
),
anEditHealthChatProfileState(
fieldErrors = persistentMapOf(
HealthChatProfileField.FirstName to HealthChatStrings.healthchat_error_field_required,
HealthChatProfileField.Website to HealthChatStrings.healthchat_error_invalid_url,
),
),
anEditHealthChatProfileState(
serverFieldErrors = persistentMapOf(
HealthChatProfileField.BusinessEmail to "The Email field is not a valid e-mail address.",
),
),
anEditHealthChatProfileState(
completenessMessage = "Bitte vervollständigen Sie Ihr Profil, bevor Sie die Benutzersuche verwenden",
),
anEditHealthChatProfileState(saveAction = AsyncAction.Loading),
)
}
fun anEditHealthChatProfileState(
userId: UserId = UserId("@erika.muster:healthchat.ch"),
loadState: AsyncData<Unit> = AsyncData.Success(Unit),
values: ImmutableMap<HealthChatProfileField, String> = aProfileFormValues(),
fieldErrors: ImmutableMap<HealthChatProfileField, Int> = persistentMapOf(),
serverFieldErrors: ImmutableMap<HealthChatProfileField, String> = persistentMapOf(),
displayName: String? = "Dr. med. Erika Muster",
avatarUrl: String? = null,
avatarActions: ImmutableList<AvatarAction> = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto),
completenessMessage: String? = null,
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
eventSink: (EditHealthChatProfileEvent) -> Unit = {},
) = EditHealthChatProfileState(
userId = userId,
loadState = loadState,
values = values,
fieldErrors = fieldErrors,
serverFieldErrors = serverFieldErrors,
displayName = displayName,
avatarUrl = avatarUrl,
avatarActions = avatarActions,
completenessMessage = completenessMessage,
saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction,
cameraPermissionState = cameraPermissionState,
eventSink = eventSink,
)
fun aProfileFormValues(): ImmutableMap<HealthChatProfileField, String> {
return mapOf(
HealthChatProfileField.Title to "Dr. med.",
HealthChatProfileField.FirstName to "Erika",
HealthChatProfileField.LastName to "Muster",
HealthChatProfileField.Specialty to "Allgemeinmedizin",
HealthChatProfileField.PracticeName to "Praxis Muster",
HealthChatProfileField.BusinessCity to "Zürich",
HealthChatProfileField.FurtherInfo to "",
HealthChatProfileField.BusinessEmail to "praxis@muster.ch",
HealthChatProfileField.BusinessTel to "+41 44 123 45 67",
HealthChatProfileField.Street to "Musterstrasse",
HealthChatProfileField.HouseNumber to "12",
HealthChatProfileField.PostalCode to "8001",
HealthChatProfileField.Website to "https://praxis-muster.ch",
).toImmutableMap()
}
@@ -0,0 +1,293 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.impl.healthChatUserMessage
import io.element.android.features.healthchat.impl.profile.HealthChatFieldRequirement
import io.element.android.features.healthchat.impl.profile.HealthChatProfileField
import io.element.android.features.healthchat.impl.profile.HealthChatProfileFormSection
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.AvatarPickerState
import io.element.android.libraries.matrix.ui.components.AvatarPickerView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditHealthChatProfileView(
state: EditHealthChatProfileState,
onEditProfileSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
fun onBackClick() {
focusManager.clearFocus()
state.eventSink(EditHealthChatProfileEvent.Exit)
}
BackHandler(enabled = true, ::onBackClick)
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
TopAppBar(
titleStr = stringResource(HealthChatStrings.healthchat_screen_edit_profile_title),
navigationIcon = { BackButton(::onBackClick) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
state.eventSink(EditHealthChatProfileEvent.Save)
},
)
}
)
},
) { padding ->
when (state.loadState) {
AsyncData.Uninitialized,
is AsyncData.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
is AsyncData.Failure -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = stringResource(HealthChatStrings.healthchat_screen_edit_profile_load_error),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center,
)
Button(
text = stringResource(CommonStrings.action_retry),
onClick = { state.eventSink(EditHealthChatProfileEvent.RetryLoad) },
)
}
}
is AsyncData.Success -> {
ProfileForm(
state = state,
onAvatarClick = {
focusManager.clearFocus()
isAvatarActionsSheetVisible.value = true
},
modifier = Modifier.padding(padding),
)
}
}
AvatarActionBottomSheet(
actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onSelectAction = { state.eventSink(EditHealthChatProfileEvent.HandleAvatarAction(it)) }
)
AsyncActionView(
async = state.saveAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(HealthChatStrings.healthchat_screen_edit_profile_saving),
)
},
confirmationDialog = { confirming ->
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
onSaveClick = { state.eventSink(EditHealthChatProfileEvent.Save) },
onDiscardClick = { state.eventSink(EditHealthChatProfileEvent.Exit) },
onDismiss = { state.eventSink(EditHealthChatProfileEvent.CloseDialog) },
)
}
}
},
onSuccess = { onEditProfileSuccess() },
errorTitle = { stringResource(HealthChatStrings.healthchat_screen_edit_profile_error_title) },
errorMessage = { error ->
when (error) {
is HealthChatAvatarTooLargeException -> stringResource(HealthChatStrings.healthchat_error_avatar_too_large)
else -> error.healthChatUserMessage()
}
},
onErrorDismiss = {
state.eventSink(EditHealthChatProfileEvent.CloseDialog)
// Field changes may already be persisted when a later step failed:
// re-load so the form shows the server's reality.
state.eventSink(EditHealthChatProfileEvent.RetryLoad)
},
)
}
PermissionsView(
state = state.cameraPermissionState,
)
}
@Composable
private fun ProfileForm(
state: EditHealthChatProfileState,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
val avatarPickerState = remember(state.avatarUrl) {
AvatarPickerState.Selected(
avatarData = AvatarData(
id = state.userId.value,
name = state.displayName,
size = AvatarSize.EditProfileDetails,
url = state.avatarUrl,
),
type = AvatarType.User,
)
}
AvatarPickerView(
state = avatarPickerState,
onClick = onAvatarClick,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.fillMaxWidth(),
text = state.userId.value,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
)
state.completenessMessage?.let { message ->
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textCriticalPrimary,
modifier = Modifier.fillMaxWidth(),
)
}
for (section in HealthChatProfileFormSection.entries) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(section.titleRes),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
for (field in section.fields) {
Spacer(modifier = Modifier.height(12.dp))
ProfileFormField(state = state, field = field)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
private fun ProfileFormField(
state: EditHealthChatProfileState,
field: HealthChatProfileField,
modifier: Modifier = Modifier,
) {
val clientError = state.fieldErrors[field]?.let { stringResource(it) }
val serverError = state.serverFieldErrors[field]
val error = clientError ?: serverError
val supportingText = error
?: if (field.requirement == HealthChatFieldRequirement.RecommendedForCompleteness) {
stringResource(HealthChatStrings.healthchat_screen_edit_profile_recommended)
} else {
null
}
TextField(
value = state.values[field].orEmpty(),
onValueChange = { state.eventSink(EditHealthChatProfileEvent.UpdateField(field, it)) },
label = stringResource(field.labelRes),
placeholder = field.placeholderRes?.let { stringResource(it) },
supportingText = supportingText,
validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None,
singleLine = field.minLines == 1,
minLines = field.minLines,
keyboardOptions = KeyboardOptions(keyboardType = field.keyboardType),
modifier = modifier.fillMaxWidth(),
)
}
@PreviewsDayNight
@Composable
internal fun EditHealthChatProfileViewPreview(
@PreviewParameter(EditHealthChatProfileStateProvider::class) state: EditHealthChatProfileState
) = ElementPreview {
EditHealthChatProfileView(
state = state,
onEditProfileSuccess = {},
)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatAccountStatusRenderer
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultHealthChatAccountStatusRenderer(
private val presenter: HealthChatAccountStatusPresenter,
) : HealthChatAccountStatusRenderer {
@Composable
override fun Render(
onCompleteProfileClick: () -> Unit,
includePendingApproval: Boolean,
modifier: Modifier,
) {
HealthChatAccountStatusView(
state = presenter.present(),
onCompleteProfileClick = onCompleteProfileClick,
includePendingApproval = includePendingApproval,
modifier = modifier,
)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.libraries.architecture.Presenter
@Inject
class HealthChatAccountStatusPresenter(
private val profileRepository: HealthChatProfileRepository,
) : Presenter<HealthChatAccountStatusState> {
@Composable
override fun present(): HealthChatAccountStatusState {
LaunchedEffect(Unit) {
// Failures fall through to Unknown below — the banners must never show errors.
profileRepository.refreshOwnProfile()
profileRepository.refreshCompleteness()
}
val ownProfile by profileRepository.ownProfile.collectAsState()
val completeness by profileRepository.completeness.collectAsState()
val status = remember(ownProfile, completeness) {
val profileData = ownProfile.dataOrNull()
val completenessData = completeness.dataOrNull()
when {
// PendingApproval wins: completing a profile does nothing for an unapproved account.
profileData?.isApproved == false -> HealthChatAccountStatus.PendingApproval(serverMessage = null)
completenessData?.isComplete == false -> HealthChatAccountStatus.IncompleteProfile(
missingFields = completenessData.missingFields,
)
profileData != null && completenessData != null -> HealthChatAccountStatus.Ok
else -> HealthChatAccountStatus.Unknown
}
}
return HealthChatAccountStatusState(status = status)
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import kotlinx.collections.immutable.ImmutableList
data class HealthChatAccountStatusState(
val status: HealthChatAccountStatus,
)
sealed interface HealthChatAccountStatus {
/** Nothing is known (e.g. the API is unreachable) — render nothing, never an error. */
data object Unknown : HealthChatAccountStatus
/** Account not approved yet. Takes precedence over [IncompleteProfile]. */
data class PendingApproval(val serverMessage: String?) : HealthChatAccountStatus
/** Approved but the server reports missing profile fields (German labels, verbatim). */
data class IncompleteProfile(val missingFields: ImmutableList<String>) : HealthChatAccountStatus
data object Ok : HealthChatAccountStatus
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import kotlinx.collections.immutable.persistentListOf
open class HealthChatAccountStatusStateProvider : PreviewParameterProvider<HealthChatAccountStatusState> {
override val values: Sequence<HealthChatAccountStatusState>
get() = sequenceOf(
HealthChatAccountStatusState(status = HealthChatAccountStatus.PendingApproval(serverMessage = null)),
HealthChatAccountStatusState(
status = HealthChatAccountStatus.IncompleteProfile(
missingFields = persistentListOf("Fachgebiet", "Praxisname / Firma", "Email (geschäftlich)"),
),
),
HealthChatAccountStatusState(status = HealthChatAccountStatus.Ok),
)
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Renders the account status banner, or nothing when there is nothing to show.
* Both banners are deliberately NOT dismissible: they resolve server-side.
*/
@Composable
fun HealthChatAccountStatusView(
state: HealthChatAccountStatusState,
onCompleteProfileClick: () -> Unit,
includePendingApproval: Boolean,
modifier: Modifier = Modifier,
) {
when (val status = state.status) {
HealthChatAccountStatus.Unknown,
HealthChatAccountStatus.Ok -> Unit
is HealthChatAccountStatus.PendingApproval -> {
if (includePendingApproval) {
Announcement(
title = stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_title),
description = status.serverMessage
?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message),
type = AnnouncementType.Informative(),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
is HealthChatAccountStatus.IncompleteProfile -> {
val localizedFields = status.missingFields.map { serverLabel ->
MissingFieldLabelMapper.mapLabel(serverLabel)?.let { stringResource(it) } ?: serverLabel
}
Announcement(
title = stringResource(HealthChatStrings.healthchat_banner_incomplete_title),
description = stringResource(
HealthChatStrings.healthchat_banner_incomplete_description,
localizedFields.joinToString(", "),
),
type = AnnouncementType.Actionable(
actionText = stringResource(HealthChatStrings.healthchat_banner_incomplete_action),
onActionClick = onCompleteProfileClick,
onDismissClick = null,
),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun HealthChatAccountStatusViewPreview(
@PreviewParameter(HealthChatAccountStatusStateProvider::class) state: HealthChatAccountStatusState
) = ElementPreview {
HealthChatAccountStatusView(
state = state,
onCompleteProfileClick = {},
includePendingApproval = true,
)
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import androidx.annotation.StringRes
import io.element.android.features.healthchat.api.HealthChatStrings
/**
* Maps the German labels the completeness endpoint returns (e.g. "Praxisname / Firma") to
* localized field strings. Unknown labels pass through verbatim — a backend copy-edit must
* degrade to German, never to a blank entry.
*/
object MissingFieldLabelMapper {
@StringRes
fun mapLabel(serverLabel: String): Int? {
return when (normalize(serverLabel)) {
"anzeigename" -> HealthChatStrings.healthchat_field_name
"fachgebiet" -> HealthChatStrings.healthchat_field_specialty
"praxisname / firma", "praxis / unternehmen" -> HealthChatStrings.healthchat_field_practice_name
"ort" -> HealthChatStrings.healthchat_field_city
"email (geschäftlich)", "e-mail (geschäftlich)" -> HealthChatStrings.healthchat_field_business_email
"telefon (geschäftlich)" -> HealthChatStrings.healthchat_field_business_tel
else -> null
}
}
private fun normalize(label: String): String {
return label.trim().lowercase().replace(Regex("\\s+"), " ")
}
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.invite
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatInviteService
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.test.FakeHealthChatInviteService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class HealthChatInvitePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - canSubmit requires an email and a long enough note`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canSubmit).isFalse()
initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch"))
initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("kurz"))
val shortNoteState = consumeItemsUntilPredicate { it.personalNote == "kurz" }.last()
assertThat(shortNoteState.canSubmit).isFalse()
shortNoteState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!"))
val readyState = consumeItemsUntilPredicate { it.canSubmit }.last()
assertThat(readyState.canSubmit).isTrue()
}
}
@Test
fun `present - an invalid email blocks sending without an api call`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HealthChatInviteEvent.UpdateEmail("keine-email"))
initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!"))
val readyState = consumeItemsUntilPredicate { it.canSubmit }.last()
readyState.eventSink(HealthChatInviteEvent.Send)
val state = consumeItemsUntilPredicate { it.emailError != null }.last()
assertThat(state.emailError)
.isEqualTo(HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_invalid_email))
assertThat(state.sendAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - a successful send exposes the verbatim server message`() = runTest {
val inviteLambda = lambdaRecorder<String, String, Result<String>> { _, _ ->
Result.success("Einladung wurde erfolgreich versendet")
}
val presenter = createPresenter(
inviteService = FakeHealthChatInviteService(inviteLambda = { email, note -> inviteLambda(email, note) }),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch"))
initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote(" Willkommen bei HealthChat! "))
val readyState = consumeItemsUntilPredicate { it.canSubmit }.last()
readyState.eventSink(HealthChatInviteEvent.Send)
val state = consumeItemsUntilPredicate { it.sendAction is AsyncAction.Success }.last()
assertThat((state.sendAction as AsyncAction.Success).data)
.isEqualTo("Einladung wurde erfolgreich versendet")
// Trimmed values are sent.
inviteLambda.assertions().isCalledOnce().with(
value("neu@beispiel.ch"),
value("Willkommen bei HealthChat!"),
)
}
}
@Test
fun `present - server validation errors land under their fields verbatim`() = runTest {
val presenter = createPresenter(
inviteService = FakeHealthChatInviteService(
inviteLambda = { _, _ ->
Result.failure(
HealthChatApiException.Validation(
status = 400,
title = "One or more validation errors occurred.",
fieldErrors = persistentMapOf(
"personalnote" to persistentListOf("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"),
),
)
)
},
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch"))
initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!"))
val readyState = consumeItemsUntilPredicate { it.canSubmit }.last()
readyState.eventSink(HealthChatInviteEvent.Send)
val state = consumeItemsUntilPredicate { it.personalNoteError != null }.last()
assertThat(state.personalNoteError)
.isEqualTo(HealthChatFieldError.Server("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"))
assertThat(state.sendAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - other failures surface as an error dialog with the verbatim message`() = runTest {
val error = HealthChatApiException.NotApproved("Ihr Konto wird derzeit geprüft.")
val presenter = createPresenter(
inviteService = FakeHealthChatInviteService(inviteLambda = { _, _ -> Result.failure(error) }),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch"))
initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!"))
val readyState = consumeItemsUntilPredicate { it.canSubmit }.last()
readyState.eventSink(HealthChatInviteEvent.Send)
val state = consumeItemsUntilPredicate { it.sendAction is AsyncAction.Failure }.last()
assertThat((state.sendAction as AsyncAction.Failure).error).isEqualTo(error)
state.eventSink(HealthChatInviteEvent.CloseDialog)
consumeItemsUntilPredicate { it.sendAction is AsyncAction.Uninitialized }
}
}
private fun createPresenter(
inviteService: HealthChatInviteService = FakeHealthChatInviteService(),
) = HealthChatInvitePresenter(inviteService = inviteService)
}
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.test.aHealthChatMember
import org.junit.Test
class HealthChatProfileValidatorTest {
private val validValues = HealthChatProfileField.entries.associateWith { field ->
when (field) {
HealthChatProfileField.FirstName -> "Erika"
HealthChatProfileField.LastName -> "Muster"
HealthChatProfileField.Specialty -> "Allgemeinmedizin"
HealthChatProfileField.PracticeName -> "Praxis Muster"
HealthChatProfileField.BusinessCity -> "Zürich"
HealthChatProfileField.BusinessEmail -> "praxis@muster.ch"
HealthChatProfileField.Website -> "https://praxis-muster.ch"
else -> ""
}
}
@Test
fun `a valid form has no errors`() {
assertThat(HealthChatProfileValidator.validate(validValues)).isEmpty()
}
@Test
fun `each required field blocks when blank`() {
val requiredFields = listOf(
HealthChatProfileField.FirstName,
HealthChatProfileField.LastName,
HealthChatProfileField.Specialty,
HealthChatProfileField.PracticeName,
HealthChatProfileField.BusinessCity,
)
for (field in requiredFields) {
val errors = HealthChatProfileValidator.validate(validValues + (field to " "))
assertThat(errors).containsEntry(field, HealthChatStrings.healthchat_error_field_required)
}
}
@Test
fun `recommended and optional fields never block when blank`() {
val values = validValues +
(HealthChatProfileField.BusinessEmail to "") +
(HealthChatProfileField.BusinessTel to "") +
(HealthChatProfileField.Website to "")
assertThat(HealthChatProfileValidator.validate(values)).isEmpty()
}
@Test
fun `an invalid email is rejected`() {
val errors = HealthChatProfileValidator.validate(validValues + (HealthChatProfileField.BusinessEmail to "keine-email"))
assertThat(errors).containsEntry(HealthChatProfileField.BusinessEmail, HealthChatStrings.healthchat_error_invalid_email)
}
@Test
fun `an invalid url is rejected and a scheme is required`() {
val errors = HealthChatProfileValidator.validate(validValues + (HealthChatProfileField.Website to "praxis-muster.ch"))
assertThat(errors).containsEntry(HealthChatProfileField.Website, HealthChatStrings.healthchat_error_invalid_url)
assertThat(
HealthChatProfileValidator.validate(validValues + (HealthChatProfileField.Website to "http://praxis-muster.ch"))
).isEmpty()
}
@Test
fun `buildProfileUpdate only contains the changed fields`() {
val baseline = aHealthChatMember()
val values = HealthChatProfileField.entries.associateWith { field ->
baseline.valueOf(field).orEmpty()
} + (HealthChatProfileField.Specialty to "Kardiologie")
val update = buildProfileUpdate(baseline, values)
assertThat(update.specialty).isEqualTo("Kardiologie")
// Exactly one field set — the other 12 stay null (omitted from the PUT).
assertThat(update.copy(specialty = null).isEmpty()).isTrue()
}
@Test
fun `buildProfileUpdate clears a field with an empty string`() {
val baseline = aHealthChatMember()
val values = HealthChatProfileField.entries.associateWith { field ->
baseline.valueOf(field).orEmpty()
} + (HealthChatProfileField.Website to "")
val update = buildProfileUpdate(baseline, values)
assertThat(update.website).isEqualTo("")
assertThat(update.copy(website = null).isEmpty()).isTrue()
}
@Test
fun `buildProfileUpdate ignores whitespace only changes`() {
val baseline = aHealthChatMember()
val values = HealthChatProfileField.entries.associateWith { field ->
baseline.valueOf(field).orEmpty()
} + (HealthChatProfileField.Specialty to " ${baseline.specialty} ")
assertThat(buildProfileUpdate(baseline, values).isEmpty()).isTrue()
}
}
@@ -0,0 +1,254 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.profile.edit
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.impl.profile.HealthChatProfileField
import io.element.android.features.healthchat.test.FakeHealthChatProfileRepository
import io.element.android.features.healthchat.test.aHealthChatCompleteness
import io.element.android.features.healthchat.test.aHealthChatMember
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.io.IOException
class EditHealthChatProfilePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - loads the profile into the form values`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
),
)
presenter.test {
val state = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
assertThat(state.values[HealthChatProfileField.FirstName]).isEqualTo("Erika")
assertThat(state.values[HealthChatProfileField.Specialty]).isEqualTo("Allgemeinmedizin")
assertThat(state.avatarUrl).isNull()
// Nothing changed yet: saving is disabled.
assertThat(state.saveButtonEnabled).isFalse()
}
}
@Test
fun `present - a failed load exposes a retryable failure`() = runTest {
var callCount = 0
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = {
callCount++
if (callCount == 1) {
Result.failure(HealthChatApiException.Network(IOException("no network")))
} else {
Result.success(aHealthChatMember())
}
},
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
),
)
presenter.test {
val failedState = consumeItemsUntilPredicate { it.loadState is AsyncData.Failure }.last()
failedState.eventSink(EditHealthChatProfileEvent.RetryLoad)
val state = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
assertThat(state.values[HealthChatProfileField.FirstName]).isEqualTo("Erika")
}
}
@Test
fun `present - save sends only the changed field and refreshes the matrix profile`() = runTest {
val updateLambda = lambdaRecorder<HealthChatProfileUpdate, Result<String>> {
Result.success("Profil erfolgreich aktualisiert")
}
val matrixClient = FakeMatrixClient()
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
updateOwnProfileLambda = { updateLambda(it) },
),
matrixClient = matrixClient,
)
presenter.test {
val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.Specialty, "Kardiologie"))
val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last()
editedState.eventSink(EditHealthChatProfileEvent.Save)
consumeItemsUntilPredicate { it.saveAction is AsyncAction.Success }
// THE diff assert: exactly one field set, twelve nulls.
updateLambda.assertions().isCalledOnce().with(
value(HealthChatProfileUpdate(specialty = "Kardiologie"))
)
}
}
@Test
fun `present - clearing a field sends an empty string`() = runTest {
val updateLambda = lambdaRecorder<HealthChatProfileUpdate, Result<String>> {
Result.success("Profil erfolgreich aktualisiert")
}
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
updateOwnProfileLambda = { updateLambda(it) },
),
)
presenter.test {
val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.Website, ""))
val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last()
editedState.eventSink(EditHealthChatProfileEvent.Save)
consumeItemsUntilPredicate { it.saveAction is AsyncAction.Success }
updateLambda.assertions().isCalledOnce().with(
value(HealthChatProfileUpdate(website = ""))
)
}
}
@Test
fun `present - a required field left blank blocks the save without any api call`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
// No updateOwnProfileLambda: any call would lambdaError().
),
)
presenter.test {
val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.FirstName, ""))
val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last()
editedState.eventSink(EditHealthChatProfileEvent.Save)
val state = consumeItemsUntilPredicate { it.fieldErrors.isNotEmpty() }.last()
assertThat(state.fieldErrors[HealthChatProfileField.FirstName])
.isEqualTo(HealthChatStrings.healthchat_error_field_required)
assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - server validation errors land under their fields without an error dialog`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
updateOwnProfileLambda = {
Result.failure(
HealthChatApiException.Validation(
status = 400,
title = "One or more validation errors occurred.",
fieldErrors = persistentMapOf(
"businessemail" to persistentListOf("The Email field is not a valid e-mail address."),
),
)
)
},
),
)
presenter.test {
val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.BusinessEmail, "neu@muster.ch"))
val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last()
editedState.eventSink(EditHealthChatProfileEvent.Save)
val state = consumeItemsUntilPredicate { it.serverFieldErrors.isNotEmpty() }.last()
assertThat(state.serverFieldErrors[HealthChatProfileField.BusinessEmail])
.isEqualTo("The Email field is not a valid e-mail address.")
assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - exiting with unsaved changes asks for confirmation`() = runTest {
val closeLambda = lambdaRecorder<Unit> { }
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
),
navigator = object : EditHealthChatProfileNavigator {
override fun close() {
closeLambda()
}
},
)
presenter.test {
val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.Specialty, "Kardiologie"))
val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last()
editedState.eventSink(EditHealthChatProfileEvent.Exit)
val confirming = consumeItemsUntilPredicate { it.saveAction is AsyncAction.ConfirmingCancellation }.last()
closeLambda.assertions().isNeverCalled()
confirming.eventSink(EditHealthChatProfileEvent.Exit)
consumeItemsUntilPredicate { it.saveAction is AsyncAction.Uninitialized }
closeLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - exiting without changes closes directly`() = runTest {
val closeLambda = lambdaRecorder<Unit> { }
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember()) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) },
),
navigator = object : EditHealthChatProfileNavigator {
override fun close() {
closeLambda()
}
},
)
presenter.test {
val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last()
loaded.eventSink(EditHealthChatProfileEvent.Exit)
closeLambda.assertions().isCalledOnce()
}
}
private fun createPresenter(
profileRepository: HealthChatProfileRepository = FakeHealthChatProfileRepository(),
matrixClient: MatrixClient = FakeMatrixClient(),
navigator: EditHealthChatProfileNavigator = object : EditHealthChatProfileNavigator {
override fun close() = lambdaError()
},
) = EditHealthChatProfilePresenter(
navigator = navigator,
profileRepository = profileRepository,
matrixClient = matrixClient,
mediaPickerProvider = FakePickerProvider(),
mediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter = FakeTemporaryUriDeleter(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()),
)
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.test.FakeHealthChatProfileRepository
import io.element.android.features.healthchat.test.aHealthChatCompleteness
import io.element.android.features.healthchat.test.aHealthChatMember
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.io.IOException
class HealthChatAccountStatusPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - a complete approved profile is Ok`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember(isApproved = true)) },
refreshCompletenessLambda = { Result.success(aHealthChatCompleteness(isComplete = true)) },
),
)
presenter.test {
val state = consumeItemsUntilPredicate { it.status == HealthChatAccountStatus.Ok }.last()
assertThat(state.status).isEqualTo(HealthChatAccountStatus.Ok)
}
}
@Test
fun `present - pending approval wins over an incomplete profile`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember(isApproved = false)) },
refreshCompletenessLambda = {
Result.success(
aHealthChatCompleteness(
isComplete = false,
missingFields = persistentListOf("Fachgebiet"),
)
)
},
),
)
presenter.test {
val state = consumeItemsUntilPredicate { it.status is HealthChatAccountStatus.PendingApproval }.last()
assertThat(state.status).isEqualTo(HealthChatAccountStatus.PendingApproval(serverMessage = null))
}
}
@Test
fun `present - an approved incomplete profile exposes the missing fields`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.success(aHealthChatMember(isApproved = true)) },
refreshCompletenessLambda = {
Result.success(
aHealthChatCompleteness(
isComplete = false,
missingFields = persistentListOf("Fachgebiet", "Praxisname / Firma"),
)
)
},
),
)
presenter.test {
val state = consumeItemsUntilPredicate { it.status is HealthChatAccountStatus.IncompleteProfile }.last()
val status = state.status as HealthChatAccountStatus.IncompleteProfile
assertThat(status.missingFields).containsExactly("Fachgebiet", "Praxisname / Firma").inOrder()
}
}
@Test
fun `present - failures degrade to Unknown and never to an error`() = runTest {
val presenter = createPresenter(
profileRepository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { Result.failure(HealthChatApiException.Network(IOException("no network"))) },
refreshCompletenessLambda = { Result.failure(HealthChatApiException.Network(IOException("no network"))) },
),
)
presenter.test {
// Every emission stays Unknown — never an error state.
val states = consumeItemsUntilTimeout()
assertThat(states.map { it.status }.distinct()).containsExactly(HealthChatAccountStatus.Unknown)
}
}
private fun createPresenter(
profileRepository: HealthChatProfileRepository = FakeHealthChatProfileRepository(),
) = HealthChatAccountStatusPresenter(profileRepository = profileRepository)
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.status
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatStrings
import org.junit.Test
class MissingFieldLabelMapperTest {
@Test
fun `the known server labels map to localized field strings`() {
assertThat(MissingFieldLabelMapper.mapLabel("Anzeigename")).isEqualTo(HealthChatStrings.healthchat_field_name)
assertThat(MissingFieldLabelMapper.mapLabel("Fachgebiet")).isEqualTo(HealthChatStrings.healthchat_field_specialty)
assertThat(MissingFieldLabelMapper.mapLabel("Praxisname / Firma")).isEqualTo(HealthChatStrings.healthchat_field_practice_name)
assertThat(MissingFieldLabelMapper.mapLabel("Ort")).isEqualTo(HealthChatStrings.healthchat_field_city)
assertThat(MissingFieldLabelMapper.mapLabel("Email (geschäftlich)")).isEqualTo(HealthChatStrings.healthchat_field_business_email)
assertThat(MissingFieldLabelMapper.mapLabel("Telefon (geschäftlich)")).isEqualTo(HealthChatStrings.healthchat_field_business_tel)
}
@Test
fun `matching is tolerant to case and whitespace`() {
assertThat(MissingFieldLabelMapper.mapLabel(" praxisname / firma ")).isEqualTo(HealthChatStrings.healthchat_field_practice_name)
}
@Test
fun `an unknown label returns null so the caller renders it verbatim`() {
assertThat(MissingFieldLabelMapper.mapLabel("Ein neues Feld")).isNull()
}
}
@@ -34,6 +34,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.healthchat.api.HealthChatEntryPoint
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
@@ -89,8 +90,9 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
// HealthChat fork: renders the member directory tab.
// HealthChat fork: renders the member directory tab and hosts the editor/invite flow.
private val memberDirectoryRenderer: MemberDirectoryRenderer,
private val healthChatEntryPoint: HealthChatEntryPoint,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
@@ -142,6 +144,10 @@ class HomeFlowNode(
@Parcelize
data class SelectNewOwnersWhenLeavingRoom(val roomId: RoomId) : NavTarget
// HealthChat fork: profile editor and member invitation.
@Parcelize
data class HealthChat(val target: HealthChatEntryPoint.InitialTarget) : NavTarget
}
private fun navigateToReportRoom(roomId: RoomId) {
@@ -264,9 +270,12 @@ class HomeFlowNode(
onOpenDm = { navigateToRoom(roomId = it, eventId = null) },
onMemberClick = { callback.navigateToUserProfile(it) },
onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) },
// Hidden until the native editor and invite screens exist.
onEditProfileClick = null,
onInviteClick = null,
onEditProfileClick = {
backstack.push(NavTarget.HealthChat(HealthChatEntryPoint.InitialTarget.EditOwnProfile))
},
onInviteClick = {
backstack.push(NavTarget.HealthChat(HealthChatEntryPoint.InitialTarget.Invite))
},
modifier = Modifier,
)
},
@@ -319,6 +328,18 @@ class HomeFlowNode(
listType = ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving,
)
}
is NavTarget.HealthChat -> {
healthChatEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = HealthChatEntryPoint.Params(navTarget.target),
callback = object : HealthChatEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
},
)
}
NavTarget.Root -> rootNode(buildContext)
}
}
@@ -43,6 +43,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
@@ -70,6 +70,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.fullscreenintent.api)
implementation(projects.features.healthchat.api)
implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api)
@@ -21,6 +21,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint
import io.element.android.features.healthchat.api.HealthChatEntryPoint
import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
@@ -36,7 +37,6 @@ import io.element.android.features.preferences.impl.moderation.ModerationAndSafe
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode
import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
@@ -65,6 +65,8 @@ class PreferencesFlowNode(
private val logoutEntryPoint: LogoutEntryPoint,
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
// HealthChat fork: the native 13-field profile editor.
private val healthChatEntryPoint: HealthChatEntryPoint,
) : BaseFlowNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
@@ -341,13 +343,18 @@ class PreferencesFlowNode(
createNode<MediaSettingsNode>(buildContext)
}
is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
val callback = object : EditUserProfileNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<EditUserProfileNode>(buildContext, listOf(inputs, callback))
// HealthChat fork: the native 13-field editor replaces the upstream
// display-name editor (the server derives the display name).
healthChatEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = HealthChatEntryPoint.Params(HealthChatEntryPoint.InitialTarget.EditOwnProfile),
callback = object : HealthChatEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
},
)
}
NavTarget.LockScreenSettings -> {
lockScreenEntryPoint.createNode(
@@ -19,6 +19,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.healthchat.api.HealthChatAccountStatusRenderer
import io.element.android.features.preferences.impl.account.PreferencesAccountCallback
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
@@ -33,6 +34,8 @@ class PreferencesRootNode(
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesRootPresenter,
private val emojiPickerRenderer: EmojiPickerRenderer,
// HealthChat fork: renders the account status banners.
private val healthChatAccountStatusRenderer: HealthChatAccountStatusRenderer,
) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount()
@@ -90,7 +93,14 @@ class PreferencesRootNode(
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile,
onSignOutClick = callback::startSignOutFlow,
onDeactivateClick = callback::startAccountDeactivationFlow
onDeactivateClick = callback::startAccountDeactivationFlow,
healthChatAccountStatus = {
healthChatAccountStatusRenderer.Render(
onCompleteProfileClick = { callback.navigateToUserProfile(state.myUser) },
includePendingApproval = true,
modifier = Modifier,
)
},
)
}
}
@@ -99,6 +99,8 @@ fun PreferencesRootView(
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
modifier: Modifier = Modifier,
// HealthChat fork: account status banners, rendered by features/healthchat.
healthChatAccountStatus: @Composable () -> Unit = {},
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -134,6 +136,8 @@ fun PreferencesRootView(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
// HealthChat fork: account status banners head the account section.
healthChatAccountStatus()
// Account settings section
PreferencesAccountView(
state = state.preferencesAccountState,
@@ -49,6 +49,7 @@ class DefaultPreferencesEntryPointTest {
logoutEntryPoint = FakeLogoutEntryPoint(),
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
)
}
val callback = object : PreferencesEntryPoint.Callback {