diff --git a/features/healthchat/api/build.gradle.kts b/features/healthchat/api/build.gradle.kts
index 9c5cf852f9..c19ffd5d94 100644
--- a/features/healthchat/api/build.gradle.kts
+++ b/features/healthchat/api/build.gradle.kts
@@ -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)
}
diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatAccountStatusRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatAccountStatusRenderer.kt
new file mode 100644
index 0000000000..678325cb7e
--- /dev/null
+++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatAccountStatusRenderer.kt
@@ -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,
+ )
+}
diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatEntryPoint.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatEntryPoint.kt
new file mode 100644
index 0000000000..4d671d1adc
--- /dev/null
+++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatEntryPoint.kt
@@ -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
+}
diff --git a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml
index ec6ffe282b..1b05cc7c04 100644
--- a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml
+++ b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml
@@ -37,4 +37,46 @@
Aktualisieren
Profil bearbeiten
Neues Mitglied einladen
+ Anzeigename
+ Titel
+ z.B. Dr. med.
+ Vorname
+ Nachname
+ z.B. Allgemeinmedizin
+ z.B. Praxis Muster
+ Ort
+ z.B. Zürich
+ praxis@beispiel.ch
+ +41 44 123 45 67
+
+ Strasse
+ Nr.
+ PLZ
+ https://praxis-muster.ch
+ Profil bearbeiten
+ Persönliche Angaben
+ Berufliche Angaben
+ Kontaktdaten
+ Praxisadresse
+ Profil wird gespeichert…
+ Profil konnte nicht gespeichert werden
+ Das Profil konnte nicht geladen werden.
+ Für ein vollständiges Profil empfohlen
+ Dieses Feld ist erforderlich
+ Bitte eine gültige E-Mail-Adresse eingeben
+ Bitte eine gültige URL eingeben (z.B. https://praxis-muster.ch)
+ Die persönliche Nachricht muss mindestens 10 Zeichen lang sein
+ Das Bild darf höchstens 2 MB gross sein.
+ Profil unvollständig
+ Bitte vervollständigen Sie Ihr Profil, damit andere Mitglieder Sie finden können. Fehlende Angaben: %1$s
+ Profil vervollständigen
+ Mitglied einladen
+ Laden Sie neue Mitglieder zu HealthChat ein, indem Sie deren E-Mail-Adresse und eine persönliche Nachricht eingeben.
+ E-Mail-Adresse
+ beispiel@domain.ch
+ Persönliche Nachricht
+ Schreiben Sie eine persönliche Nachricht an das neue Mitglied…
+ %1$d/10 Zeichen
+ Einladung senden
+ Einladung wird gesendet…
diff --git a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml
index 49df77d036..f7c435fb75 100644
--- a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml
+++ b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml
@@ -38,4 +38,45 @@
Actualiser
Modifier le profil
Inviter un nouveau membre
+ Nom affiché
+ Titre
+ p. ex. Dr méd.
+ Prénom
+ Nom
+ p. ex. médecine générale
+ p. ex. Praxis Muster
+ Ville
+ p. ex. Zurich
+ cabinet@exemple.ch
+ +41 44 123 45 67
+ Rue
+ N°
+ NPA
+ https://praxis-muster.ch
+ Modifier le profil
+ Informations personnelles
+ Informations professionnelles
+ Coordonnées
+ Adresse du cabinet
+ Enregistrement du profil…
+ Impossible d’enregistrer le profil
+ Le profil n’a pas pu être chargé.
+ Recommandé pour un profil complet
+ Ce champ est obligatoire
+ Veuillez saisir une adresse e-mail valide
+ Veuillez saisir une URL valide (p. ex. https://praxis-muster.ch)
+ Le message personnel doit contenir au moins 10 caractères
+ L’image ne doit pas dépasser 2 Mo.
+ Profil incomplet
+ Veuillez compléter votre profil pour que les autres membres puissent vous trouver. Informations manquantes : %1$s
+ Compléter le profil
+ Inviter un membre
+ Invitez de nouveaux membres sur HealthChat en saisissant leur adresse e-mail et un message personnel.
+ Adresse e-mail
+ exemple@domaine.ch
+ Message personnel
+ Écrivez un message personnel au nouveau membre…
+ %1$d/10 caractères
+ Envoyer l’invitation
+ Envoi de l’invitation…
diff --git a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml
index 20af35a02b..af22c6883a 100644
--- a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml
+++ b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml
@@ -38,4 +38,45 @@
Aggiorna
Modifica profilo
Invita un nuovo membro
+ Nome visualizzato
+ Titolo
+ ad es. Dr. med.
+ Nome
+ Cognome
+ ad es. medicina generale
+ ad es. Praxis Muster
+ Città
+ ad es. Zurigo
+ studio@esempio.ch
+ +41 44 123 45 67
+ Via
+ N.
+ CAP
+ https://praxis-muster.ch
+ Modifica profilo
+ Dati personali
+ Dati professionali
+ Contatti
+ Indirizzo dello studio
+ Salvataggio del profilo…
+ Impossibile salvare il profilo
+ Impossibile caricare il profilo.
+ Consigliato per un profilo completo
+ Questo campo è obbligatorio
+ Inserisci un indirizzo e-mail valido
+ Inserisci un URL valido (ad es. https://praxis-muster.ch)
+ Il messaggio personale deve contenere almeno 10 caratteri
+ L’immagine non deve superare i 2 MB.
+ Profilo incompleto
+ Completa il tuo profilo affinché gli altri membri possano trovarti. Dati mancanti: %1$s
+ Completa il profilo
+ Invita un membro
+ Invita nuovi membri su HealthChat inserendo il loro indirizzo e-mail e un messaggio personale.
+ Indirizzo e-mail
+ esempio@dominio.ch
+ Messaggio personale
+ Scrivi un messaggio personale al nuovo membro…
+ %1$d/10 caratteri
+ Invia invito
+ Invio dell’invito…
diff --git a/features/healthchat/api/src/main/res/values/strings_healthchat.xml b/features/healthchat/api/src/main/res/values/strings_healthchat.xml
index f17fbea476..15bdb537bd 100644
--- a/features/healthchat/api/src/main/res/values/strings_healthchat.xml
+++ b/features/healthchat/api/src/main/res/values/strings_healthchat.xml
@@ -38,4 +38,45 @@
Refresh
Edit profile
Invite a new member
+ Display name
+ Title
+ e.g. Dr. med.
+ First name
+ Last name
+ e.g. general medicine
+ e.g. Praxis Muster
+ City
+ e.g. Zurich
+ praxis@example.ch
+ +41 44 123 45 67
+ Street
+ No.
+ Postal code
+ https://praxis-muster.ch
+ Edit profile
+ Personal details
+ Professional details
+ Contact details
+ Practice address
+ Saving profile…
+ Couldn’t save the profile
+ The profile couldn’t be loaded.
+ Recommended for a complete profile
+ This field is required
+ Please enter a valid email address
+ Please enter a valid URL (e.g. https://praxis-muster.ch)
+ The personal message must be at least 10 characters long
+ The image must not exceed 2 MB.
+ Profile incomplete
+ Please complete your profile so other members can find you. Missing details: %1$s
+ Complete profile
+ Invite a member
+ Invite new members to HealthChat by entering their email address and a personal message.
+ Email address
+ example@domain.ch
+ Personal message
+ Write a personal message to the new member…
+ %1$d/10 characters
+ Send invitation
+ Sending invitation…
diff --git a/features/healthchat/impl/build.gradle.kts b/features/healthchat/impl/build.gradle.kts
index 7042791b93..b63e2da36b 100644
--- a/features/healthchat/impl/build.gradle.kts
+++ b/features/healthchat/impl/build.gradle.kts
@@ -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)
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatEntryPoint.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatEntryPoint.kt
new file mode 100644
index 0000000000..bff86df033
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatEntryPoint.kt
@@ -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(buildContext, plugins = listOf(params, callback))
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatFlowNode.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatFlowNode.kt
new file mode 100644
index 0000000000..6ebd5eaa1c
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatFlowNode.kt
@@ -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,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = plugins
+ .filterIsInstance()
+ .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(buildContext, plugins = plugins)
+ NavTarget.Invite -> createNode(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
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt
index 492401dd88..f5d5d7dd0f 100644
--- a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt
@@ -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,
+ )
+ }
}
}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteEvent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteEvent.kt
new file mode 100644
index 0000000000..8d0265deb8
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteEvent.kt
@@ -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
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteNode.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteNode.kt
new file mode 100644
index 0000000000..e61df92bbd
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteNode.kt
@@ -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,
+ 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,
+ )
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenter.kt
new file mode 100644
index 0000000000..bff93cbc2b
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenter.kt
@@ -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 {
+ @Composable
+ override fun present(): HealthChatInviteState {
+ val coroutineScope = rememberCoroutineScope()
+ var email by rememberSaveable { mutableStateOf("") }
+ var personalNote by rememberSaveable { mutableStateOf("") }
+ var emailError by remember { mutableStateOf(null) }
+ var personalNoteError by remember { mutableStateOf(null) }
+ val sendAction = remember { mutableStateOf>(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,
+ )
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteState.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteState.kt
new file mode 100644
index 0000000000..59cb84f092
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteState.kt
@@ -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,
+ val eventSink: (HealthChatInviteEvent) -> Unit,
+)
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteStateProvider.kt
new file mode 100644
index 0000000000..530054892f
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteStateProvider.kt
@@ -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 {
+ override val values: Sequence
+ 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 = AsyncAction.Uninitialized,
+ eventSink: (HealthChatInviteEvent) -> Unit = {},
+) = HealthChatInviteState(
+ email = email,
+ personalNote = personalNote,
+ emailError = emailError,
+ personalNoteError = personalNoteError,
+ canSubmit = canSubmit,
+ sendAction = sendAction,
+ eventSink = eventSink,
+)
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteView.kt
new file mode 100644
index 0000000000..b07893e13d
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteView.kt
@@ -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 = {},
+ )
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileField.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileField.kt
new file mode 100644
index 0000000000..ed56171622
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileField.kt
@@ -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,
+) {
+ 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,
+): 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),
+ )
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidator.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidator.kt
new file mode 100644
index 0000000000..554b48eaa1
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidator.kt
@@ -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): Map {
+ val errors = mutableMapOf()
+ 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())
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileEvent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileEvent.kt
new file mode 100644
index 0000000000..97def05d50
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileEvent.kt
@@ -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
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNavigator.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNavigator.kt
new file mode 100644
index 0000000000..b755298536
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNavigator.kt
@@ -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()
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNode.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNode.kt
new file mode 100644
index 0000000000..6b7c60998c
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNode.kt
@@ -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,
+ 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,
+ )
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenter.kt
new file mode 100644
index 0000000000..8f255114a6
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenter.kt
@@ -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 {
+ 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.Uninitialized) }
+ var baseline by remember { mutableStateOf(null) }
+ var values by remember { mutableStateOf(persistentMapOf()) }
+ var fieldErrors by remember { mutableStateOf(persistentMapOf()) }
+ var serverFieldErrors by remember { mutableStateOf(persistentMapOf()) }
+ var avatarUri by remember { mutableStateOf(null) }
+ var completenessMessage by remember { mutableStateOf(null) }
+ val saveAction = remember { mutableStateOf>(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().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().putAll(errors)
+ return
+ }
+ coroutineScope.saveChanges(
+ baseline = currentBaseline,
+ values = values,
+ avatarUri = avatarUri,
+ action = saveAction,
+ onValidationError = { validation ->
+ serverFieldErrors = persistentMapOf().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,
+ avatarUri: String?,
+ action: MutableState>,
+ 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 {
+ 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")
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileState.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileState.kt
new file mode 100644
index 0000000000..214ea6d1c9
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileState.kt
@@ -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,
+ val values: ImmutableMap,
+ /** Client-side validation errors (string resources). */
+ val fieldErrors: ImmutableMap,
+ /** Verbatim server-side validation errors, matched by api key. */
+ val serverFieldErrors: ImmutableMap,
+ val displayName: String?,
+ val avatarUrl: String?,
+ val avatarActions: ImmutableList,
+ /** Verbatim server advisory when the profile is incomplete; never blocks saving. */
+ val completenessMessage: String?,
+ val saveButtonEnabled: Boolean,
+ val saveAction: AsyncAction,
+ val cameraPermissionState: PermissionsState,
+ val eventSink: (EditHealthChatProfileEvent) -> Unit,
+)
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileStateProvider.kt
new file mode 100644
index 0000000000..ebbeac1992
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileStateProvider.kt
@@ -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 {
+ override val values: Sequence
+ 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 = AsyncData.Success(Unit),
+ values: ImmutableMap = aProfileFormValues(),
+ fieldErrors: ImmutableMap = persistentMapOf(),
+ serverFieldErrors: ImmutableMap = persistentMapOf(),
+ displayName: String? = "Dr. med. Erika Muster",
+ avatarUrl: String? = null,
+ avatarActions: ImmutableList = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto),
+ completenessMessage: String? = null,
+ saveButtonEnabled: Boolean = true,
+ saveAction: AsyncAction = 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 {
+ 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()
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileView.kt
new file mode 100644
index 0000000000..df851bdd04
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileView.kt
@@ -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 = {},
+ )
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/DefaultHealthChatAccountStatusRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/DefaultHealthChatAccountStatusRenderer.kt
new file mode 100644
index 0000000000..511e12227d
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/DefaultHealthChatAccountStatusRenderer.kt
@@ -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,
+ )
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenter.kt
new file mode 100644
index 0000000000..1f2130d488
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenter.kt
@@ -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 {
+ @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)
+ }
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusState.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusState.kt
new file mode 100644
index 0000000000..caa29fdcec
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusState.kt
@@ -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) : HealthChatAccountStatus
+
+ data object Ok : HealthChatAccountStatus
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusStateProvider.kt
new file mode 100644
index 0000000000..c98f5bd767
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusStateProvider.kt
@@ -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 {
+ override val values: Sequence
+ get() = sequenceOf(
+ HealthChatAccountStatusState(status = HealthChatAccountStatus.PendingApproval(serverMessage = null)),
+ HealthChatAccountStatusState(
+ status = HealthChatAccountStatus.IncompleteProfile(
+ missingFields = persistentListOf("Fachgebiet", "Praxisname / Firma", "Email (geschäftlich)"),
+ ),
+ ),
+ HealthChatAccountStatusState(status = HealthChatAccountStatus.Ok),
+ )
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusView.kt
new file mode 100644
index 0000000000..7723f645af
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusView.kt
@@ -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,
+ )
+}
diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapper.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapper.kt
new file mode 100644
index 0000000000..dfd2207eb4
--- /dev/null
+++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapper.kt
@@ -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+"), " ")
+ }
+}
diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenterTest.kt
new file mode 100644
index 0000000000..1edb6e8d89
--- /dev/null
+++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenterTest.kt
@@ -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> { _, _ ->
+ 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)
+}
diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidatorTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidatorTest.kt
new file mode 100644
index 0000000000..4d4300d9f7
--- /dev/null
+++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidatorTest.kt
@@ -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()
+ }
+}
diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenterTest.kt
new file mode 100644
index 0000000000..6fc2611a0d
--- /dev/null
+++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenterTest.kt
@@ -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> {
+ 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> {
+ 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 { }
+ 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 { }
+ 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()),
+ )
+}
diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenterTest.kt
new file mode 100644
index 0000000000..1251760e8c
--- /dev/null
+++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenterTest.kt
@@ -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)
+}
diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapperTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapperTest.kt
new file mode 100644
index 0000000000..efbbd076e2
--- /dev/null
+++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapperTest.kt
@@ -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()
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
index 567ce4f212..083311d242 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
@@ -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(
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)
}
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
index 70f197650f..09cd1212a1 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
@@ -43,6 +43,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
+ healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index 277d78b50c..22fa5f7e4d 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -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)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
index a49053546d..bb910712bf 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
@@ -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(
backstack = BackStack(
initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(),
@@ -341,13 +343,18 @@ class PreferencesFlowNode(
createNode(buildContext)
}
is NavTarget.UserProfile -> {
- val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
- val callback = object : EditUserProfileNode.Callback {
- override fun onDone() {
- backstack.pop()
- }
- }
- createNode(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(
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
index 93d6d25cbd..02e68308f9 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
@@ -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,
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,
+ )
+ },
)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 79aabeea2e..99909ebf9e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -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,
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
index 80490255a6..bd569d51cc 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
@@ -49,6 +49,7 @@ class DefaultPreferencesEntryPointTest {
logoutEntryPoint = FakeLogoutEntryPoint(),
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
+ healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
)
}
val callback = object : PreferencesEntryPoint.Callback {
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_0_en.png
new file mode 100644
index 0000000000..a6d0ddbacc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:618c3386712d1429f79f58d7a178d8f2680553e2f31ccc70c6a2e07f3a00ceb3
+size 43138
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_1_en.png
new file mode 100644
index 0000000000..9ad4b2d15c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2f80acea887449bd6dddffe5dc4673afdb882135236872773299a2e1cc2e58ed
+size 44492
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_2_en.png
new file mode 100644
index 0000000000..78cf35595d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:26eca6af4689aeb8ad36eec3de5922c9cb69232ca5b162eead72704aa82f00ab
+size 44805
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_3_en.png
new file mode 100644
index 0000000000..359bb8748c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3d028131b40f2565a59663c819a78f6f4f235f32d2fde673f563396a2b9f8e7e
+size 49553
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_4_en.png
new file mode 100644
index 0000000000..bdfd959e0d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4ec06536ca66c5f6f99822c2cd300f838537d0a0f3f78aecc917f9b038754935
+size 37752
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_0_en.png
new file mode 100644
index 0000000000..875e27a66f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9a60fc8e6eb794747de645ab472cfe670218be901f99b46fd177b661c9091560
+size 41612
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_1_en.png
new file mode 100644
index 0000000000..f7d6d2632a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e337e0e0aee9ac8cd2fc40e54be2ed4aac148dfa27c0f2464741e77427a8b205
+size 42963
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_2_en.png
new file mode 100644
index 0000000000..2ac906b2a0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fecfe2f115ac8a3c460ae9be8d1feb134763d2a8182d5d5707a3eecece118338
+size 43109
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_3_en.png
new file mode 100644
index 0000000000..53eb8645cf
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3662717c3921544837ec24c9bddba85b5388ad615f902059933a42b8000f9870
+size 47758
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_4_en.png
new file mode 100644
index 0000000000..e1be883abf
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d230fc90d32b8484ba2cdb4b585b75dc2a658e81f1dfa5d9be9c16589ccb4d2c
+size 36147
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_0_en.png
new file mode 100644
index 0000000000..4bfcb121a8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9335aa7d97264db2f09aa38863eca1b7b41d5940422fffcfcf45fa35c0c4e035
+size 35708
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_1_en.png
new file mode 100644
index 0000000000..db12c4ce07
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f5b8cedd4ce0eab31827babbd4d2fd6f3f23f9ee40fc26b89c6ea0da82981872
+size 9183
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_2_en.png
new file mode 100644
index 0000000000..54f3bf1866
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eca9bc03c1b36e3d082e4864e5959fc1db1cba27d24f9efb064c3c20dfa49980
+size 14751
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_3_en.png
new file mode 100644
index 0000000000..f127bf76e2
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f356fbe0bfad0c13a8b7e3debd54bdb737b37dc21279043527c6ea6c36698e81
+size 37316
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_4_en.png
new file mode 100644
index 0000000000..4bfcb121a8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9335aa7d97264db2f09aa38863eca1b7b41d5940422fffcfcf45fa35c0c4e035
+size 35708
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_5_en.png
new file mode 100644
index 0000000000..177d6a7a5e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8e5bd8c696ffe5370f836d6bb66a2009d6ca29128917f2b13b5fdbb291c52579
+size 42273
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_6_en.png
new file mode 100644
index 0000000000..61a2b61e58
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:69397c4893fc4a507908f51a08ce3835bb73497b2bf1c73ef769859acfe95488
+size 35336
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_0_en.png
new file mode 100644
index 0000000000..173644ade3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9296c373659536ae15f2eefbb530a3900e501c3c98c78c2a31183471b815dbb7
+size 35078
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_1_en.png
new file mode 100644
index 0000000000..bb342e1d37
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e8913fc1669b8d424fc3598c3b232659bfd731effb4f0da3c2335e6c8bed3412
+size 8943
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_2_en.png
new file mode 100644
index 0000000000..a26c5287e8
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:81c34429c5ef315a8208772aa4ca31067c7ca14eb51fc133a19a26f688bc9979
+size 14305
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_3_en.png
new file mode 100644
index 0000000000..ca804510fb
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1fd97e6a9815559d582abb0316ad95ca0fa886eb3d45b8cd5fcd5c9328b10fcd
+size 36758
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_4_en.png
new file mode 100644
index 0000000000..173644ade3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9296c373659536ae15f2eefbb530a3900e501c3c98c78c2a31183471b815dbb7
+size 35078
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_5_en.png
new file mode 100644
index 0000000000..b7c956e2f7
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ce796b2da3b8363e26d980568800810ad47b51c75a1bfc28b2a53d0b20cb8996
+size 41499
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_6_en.png
new file mode 100644
index 0000000000..60ce9394a9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7efd221b39331f72f031c798675f0655d69a23c8f30446656c38401719d8c024
+size 34956
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_0_en.png
new file mode 100644
index 0000000000..9031d83945
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0ff1b8dfc1a3e816c8633cc2c561ddfc30469115891d81da281fa55355e1769d
+size 23368
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_1_en.png
new file mode 100644
index 0000000000..faf58324f9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5b25a34776c04b5ef5d1bffcaeb08d0d3df49d598dbc3c24fa217c502e394433
+size 29231
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_2_en.png
new file mode 100644
index 0000000000..bd73e322ab
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
+size 3657
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_0_en.png
new file mode 100644
index 0000000000..e0eb06581e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8b7950eb492e517b9f320731ba8c89b1a6e5799db09d30afd29430544da4da7d
+size 22782
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_1_en.png
new file mode 100644
index 0000000000..585422c9ca
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cb24493aeb8ae5026f22a8cf68ea90d2a2e35221d592ebcd5e887abf922821ef
+size 28461
diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_2_en.png
new file mode 100644
index 0000000000..bd73e322ab
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
+size 3657