feat(healthchat): show profile fields on profile screens

Renderer slot in UserProfileView (all three profile screens) shows
specialty, practice, address, contact links and further info from
the HealthChat API, with inline retry on failure.
This commit is contained in:
2026-07-30 20:54:50 +02:00
committed by Sedat Qaja
parent 9f8a39bb74
commit 8f82e578f4
31 changed files with 845 additions and 1 deletions
@@ -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.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.UserId
/**
* Renders the HealthChat profile fields (specialty, practice, contact details, …) for a user.
*
* Injected into the upstream profile screens as a slot so the fork's only upstream diff is
* one defaulted parameter plus the injection (same pattern as `InvitePeopleRenderer`).
*/
interface HealthChatProfileSectionRenderer {
@Composable
fun Render(
userId: UserId,
isCurrentUser: Boolean,
modifier: Modifier,
)
}
@@ -11,4 +11,13 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Ein Netzwerkfehler ist aufgetreten. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.</string>
<string name="healthchat_error_unknown">Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</string>
<string name="healthchat_field_specialty">Fachgebiet</string>
<string name="healthchat_field_practice_name">Praxis / Unternehmen</string>
<string name="healthchat_field_address">Adresse</string>
<string name="healthchat_field_business_email">E-Mail (geschäftlich)</string>
<string name="healthchat_field_business_tel">Telefon (geschäftlich)</string>
<string name="healthchat_field_website">Webseite</string>
<string name="healthchat_field_further_info">Weitere Infos / Qualifikationen</string>
<string name="healthchat_profile_error_loading">Die HealthChat-Profildaten konnten nicht geladen werden.</string>
<string name="healthchat_profile_not_approved_hint">Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist.</string>
</resources>
@@ -11,4 +11,13 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Une erreur réseau est survenue. Veuillez vérifier votre connexion et réessayer.</string>
<string name="healthchat_error_unknown">Une erreur est survenue. Veuillez réessayer.</string>
<string name="healthchat_field_specialty">Spécialité</string>
<string name="healthchat_field_practice_name">Cabinet / Entreprise</string>
<string name="healthchat_field_address">Adresse</string>
<string name="healthchat_field_business_email">E-mail (professionnel)</string>
<string name="healthchat_field_business_tel">Téléphone (professionnel)</string>
<string name="healthchat_field_website">Site web</string>
<string name="healthchat_field_further_info">Informations complémentaires / qualifications</string>
<string name="healthchat_profile_error_loading">Les détails du profil HealthChat n’ont pas pu être chargés.</string>
<string name="healthchat_profile_not_approved_hint">Les détails du profil seront visibles dès que votre compte aura été approuvé.</string>
</resources>
@@ -11,4 +11,13 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Si è verificato un errore di rete. Controlla la connessione e riprova.</string>
<string name="healthchat_error_unknown">Qualcosa è andato storto. Riprova.</string>
<string name="healthchat_field_specialty">Specializzazione</string>
<string name="healthchat_field_practice_name">Studio / Azienda</string>
<string name="healthchat_field_address">Indirizzo</string>
<string name="healthchat_field_business_email">E-mail (professionale)</string>
<string name="healthchat_field_business_tel">Telefono (professionale)</string>
<string name="healthchat_field_website">Sito web</string>
<string name="healthchat_field_further_info">Ulteriori informazioni / qualifiche</string>
<string name="healthchat_profile_error_loading">Impossibile caricare i dettagli del profilo HealthChat.</string>
<string name="healthchat_profile_not_approved_hint">I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato.</string>
</resources>
@@ -12,4 +12,13 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">A network error occurred. Please check your connection and try again.</string>
<string name="healthchat_error_unknown">Something went wrong. Please try again.</string>
<string name="healthchat_field_specialty">Specialty</string>
<string name="healthchat_field_practice_name">Practice / Company</string>
<string name="healthchat_field_address">Address</string>
<string name="healthchat_field_business_email">Email (business)</string>
<string name="healthchat_field_business_tel">Phone (business)</string>
<string name="healthchat_field_website">Website</string>
<string name="healthchat_field_further_info">Further info / qualifications</string>
<string name="healthchat_profile_error_loading">The HealthChat profile details couldn’t be loaded.</string>
<string name="healthchat_profile_not_approved_hint">Profile details become visible once your account has been approved.</string>
</resources>
+3 -1
View File
@@ -54,11 +54,13 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs)
testCommonDependencies(libs, true)
testImplementation(libs.network.mockwebserver)
testImplementation(projects.features.healthchat.test)
testImplementation(projects.libraries.matrix.test)
@@ -0,0 +1,37 @@
/*
* 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.section
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatProfileSectionRenderer
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(SessionScope::class)
class DefaultHealthChatProfileSectionRenderer(
private val presenterFactory: HealthChatProfileSectionPresenter.Factory,
) : HealthChatProfileSectionRenderer {
@Composable
override fun Render(
userId: UserId,
isCurrentUser: Boolean,
modifier: Modifier,
) {
val presenter = remember(userId, isCurrentUser) {
presenterFactory.create(userId = userId, isCurrentUser = isCurrentUser)
}
HealthChatProfileSectionView(
state = presenter.present(),
modifier = modifier,
)
}
}
@@ -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.section
sealed interface HealthChatProfileSectionEvents {
data object Retry : HealthChatProfileSectionEvents
}
@@ -0,0 +1,77 @@
/*
* 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.section
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
@AssistedInject
class HealthChatProfileSectionPresenter(
@Assisted private val userId: UserId,
@Assisted private val isCurrentUser: Boolean,
private val directoryService: HealthChatDirectoryService,
private val profileRepository: HealthChatProfileRepository,
) : Presenter<HealthChatProfileSectionState> {
@AssistedFactory
interface Factory {
fun create(userId: UserId, isCurrentUser: Boolean): HealthChatProfileSectionPresenter
}
@Composable
override fun present(): HealthChatProfileSectionState {
var retryTrigger by rememberSaveable { mutableIntStateOf(0) }
// isCurrentUser is fixed for the lifetime of the presenter, so the branch is stable.
val profile = if (isCurrentUser) {
val ownProfile by profileRepository.ownProfile.collectAsState()
LaunchedEffect(retryTrigger) {
profileRepository.refreshOwnProfile()
}
ownProfile
} else {
var memberProfile by remember {
mutableStateOf<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
}
LaunchedEffect(retryTrigger) {
memberProfile = AsyncData.Loading(memberProfile.dataOrNull())
directoryService.getMember(userId).fold(
onSuccess = { memberProfile = AsyncData.Success(it) },
onFailure = { memberProfile = AsyncData.Failure(it, memberProfile.dataOrNull()) },
)
}
memberProfile
}
fun handleEvents(event: HealthChatProfileSectionEvents) {
when (event) {
HealthChatProfileSectionEvents.Retry -> retryTrigger++
}
}
return HealthChatProfileSectionState(
profile = profile,
eventSink = ::handleEvents,
)
}
}
@@ -0,0 +1,17 @@
/*
* 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.section
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.libraries.architecture.AsyncData
data class HealthChatProfileSectionState(
val profile: AsyncData<HealthChatMember>,
val eventSink: (HealthChatProfileSectionEvents) -> Unit,
)
@@ -0,0 +1,85 @@
/*
* 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.section
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
open class HealthChatProfileSectionStateProvider : PreviewParameterProvider<HealthChatProfileSectionState> {
override val values: Sequence<HealthChatProfileSectionState>
get() = sequenceOf(
aHealthChatProfileSectionState(profile = AsyncData.Success(aProfileSectionMember())),
aHealthChatProfileSectionState(
profile = AsyncData.Success(
aProfileSectionMember(
specialty = "Allgemeinmedizin",
practiceName = null,
businessCity = null,
businessEmail = null,
businessTel = null,
street = null,
houseNumber = null,
postalCode = null,
website = null,
furtherInfo = null,
)
)
),
aHealthChatProfileSectionState(profile = AsyncData.Loading()),
aHealthChatProfileSectionState(profile = AsyncData.Failure(HealthChatApiException.NotFound(null))),
aHealthChatProfileSectionState(profile = AsyncData.Failure(HealthChatApiException.NotApproved(null))),
aHealthChatProfileSectionState(
profile = AsyncData.Failure(HealthChatApiException.Network(RuntimeException("no network")))
),
)
}
fun aHealthChatProfileSectionState(
profile: AsyncData<HealthChatMember> = AsyncData.Uninitialized,
eventSink: (HealthChatProfileSectionEvents) -> Unit = {},
) = HealthChatProfileSectionState(
profile = profile,
eventSink = eventSink,
)
fun aProfileSectionMember(
userId: UserId = UserId("@erika.muster:healthchat.ch"),
displayName: String? = "Dr. med. Erika Muster",
specialty: String? = "Allgemeinmedizin",
practiceName: String? = "Praxis Muster",
businessCity: String? = "Zürich",
businessEmail: String? = "praxis@muster.ch",
businessTel: String? = "+41 44 123 45 67",
street: String? = "Musterstrasse",
houseNumber: String? = "12",
postalCode: String? = "8001",
website: String? = "https://praxis-muster.ch",
furtherInfo: String? = "FMH Allgemeine Innere Medizin",
) = HealthChatMember(
userId = userId,
displayName = displayName,
avatarUrl = null,
title = "Dr. med.",
firstName = "Erika",
lastName = "Muster",
specialty = specialty,
practiceName = practiceName,
businessCity = businessCity,
furtherInfo = furtherInfo,
businessEmail = businessEmail,
businessTel = businessTel,
street = street,
houseNumber = houseNumber,
postalCode = postalCode,
website = website,
isApproved = true,
)
@@ -0,0 +1,212 @@
/*
* 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.section
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Column
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
/**
* The HealthChat profile fields block rendered on the profile screens.
*
* Error behaviour is inline degradation: the host screen's primary job is Matrix identity,
* so loading renders nothing, a missing directory record ([HealthChatApiException.NotFound],
* e.g. system accounts) renders nothing, an unapproved caller gets a single muted hint, and
* any other failure gets a single retry row. Stale data is never shown.
*/
@Composable
fun HealthChatProfileSectionView(
state: HealthChatProfileSectionState,
modifier: Modifier = Modifier,
) {
when (val profile = state.profile) {
AsyncData.Uninitialized,
is AsyncData.Loading -> Unit
is AsyncData.Failure -> when (profile.error) {
is HealthChatApiException.NotFound -> Unit
is HealthChatApiException.NotApproved -> NotApprovedHint(modifier = modifier)
else -> LoadingErrorRow(
onRetry = { state.eventSink(HealthChatProfileSectionEvents.Retry) },
modifier = modifier,
)
}
is AsyncData.Success -> ProfileFieldList(
member = profile.data,
modifier = modifier,
)
}
}
@Composable
private fun ProfileFieldList(
member: HealthChatMember,
modifier: Modifier = Modifier,
) {
val activity = LocalActivity.current
val context = LocalContext.current
val darkTheme = ElementTheme.isLightTheme.not()
Column(modifier = modifier.fillMaxWidth()) {
member.specialty?.let {
ProfileFieldRow(
value = it,
labelRes = HealthChatStrings.healthchat_field_specialty,
icon = CompoundIcons.Info(),
)
}
member.practiceName?.let {
ProfileFieldRow(
value = it,
labelRes = HealthChatStrings.healthchat_field_practice_name,
icon = CompoundIcons.Company(),
)
}
member.formattedAddress()?.let {
ProfileFieldRow(
value = it,
labelRes = HealthChatStrings.healthchat_field_address,
icon = CompoundIcons.LocationPin(),
)
}
member.businessEmail?.let {
ProfileFieldRow(
value = it,
labelRes = HealthChatStrings.healthchat_field_business_email,
icon = CompoundIcons.Email(),
onClick = { context.openUrlInExternalApp("mailto:$it") },
)
}
member.businessTel?.let {
ProfileFieldRow(
value = it,
labelRes = HealthChatStrings.healthchat_field_business_tel,
icon = CompoundIcons.VoiceCall(),
onClick = { context.openUrlInExternalApp("tel:$it") },
)
}
member.website?.let { website ->
ProfileFieldRow(
value = website,
labelRes = HealthChatStrings.healthchat_field_website,
icon = CompoundIcons.WebBrowser(),
onClick = {
val url = website.withHttpScheme()
if (activity != null) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
} else {
context.openUrlInExternalApp(url)
}
},
)
}
member.furtherInfo?.let {
ProfileFieldRow(
value = it,
labelRes = HealthChatStrings.healthchat_field_further_info,
icon = CompoundIcons.Document(),
)
}
}
}
@Composable
private fun ProfileFieldRow(
value: String,
labelRes: Int,
icon: ImageVector,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
ListItem(
headlineContent = { Text(value) },
supportingContent = { Text(stringResource(labelRes)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(icon)),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun NotApprovedHint(
modifier: Modifier = Modifier,
) {
Text(
text = stringResource(HealthChatStrings.healthchat_profile_not_approved_hint),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun LoadingErrorRow(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text(stringResource(HealthChatStrings.healthchat_profile_error_loading)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Error())),
trailingContent = ListItemContent.Custom {
TextButton(
text = stringResource(CommonStrings.action_retry),
onClick = onRetry,
)
},
modifier = modifier,
)
}
/**
* "Musterstrasse 12\n8001 Zürich" — blank parts are dropped, null when nothing is set.
*/
private fun HealthChatMember.formattedAddress(): String? {
val streetLine = listOfNotNull(street, houseNumber).joinToString(" ")
val cityLine = listOfNotNull(postalCode, businessCity).joinToString(" ")
return listOf(streetLine, cityLine)
.filter { it.isNotBlank() }
.joinToString("\n")
.takeIf { it.isNotBlank() }
}
private fun String.withHttpScheme(): String {
return if (startsWith("http://") || startsWith("https://")) this else "https://$this"
}
@PreviewsDayNight
@Composable
internal fun HealthChatProfileSectionViewPreview(
@PreviewParameter(HealthChatProfileSectionStateProvider::class) state: HealthChatProfileSectionState
) = ElementPreview {
HealthChatProfileSectionView(state = state)
}
@@ -0,0 +1,129 @@
/*
* 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.section
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.test.FakeHealthChatDirectoryService
import io.element.android.features.healthchat.test.FakeHealthChatProfileRepository
import io.element.android.features.healthchat.test.aHealthChatMember
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_USER_ID
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.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.io.IOException
class HealthChatProfileSectionPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - own profile is loaded through the repository`() = runTest {
val member = aHealthChatMember()
val refreshLambda = lambdaRecorder<Result<HealthChatMember>> { Result.success(member) }
val repository = FakeHealthChatProfileRepository(
refreshOwnProfileLambda = { refreshLambda() },
)
val presenter = createPresenter(isCurrentUser = true, profileRepository = repository)
presenter.test {
val state = consumeItemsUntilPredicate { it.profile is AsyncData.Success }.last()
assertThat(state.profile).isEqualTo(AsyncData.Success(member))
refreshLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - another member is loaded through the directory service`() = runTest {
val member = aHealthChatMember(userId = A_USER_ID)
val getMemberLambda = lambdaRecorder<UserId, Result<HealthChatMember>> { Result.success(member) }
val directoryService = FakeHealthChatDirectoryService(
getMemberLambda = { getMemberLambda(it) },
)
val presenter = createPresenter(isCurrentUser = false, directoryService = directoryService)
presenter.test {
val state = consumeItemsUntilPredicate { it.profile is AsyncData.Success }.last()
assertThat(state.profile).isEqualTo(AsyncData.Success(member))
getMemberLambda.assertions().isCalledOnce().with(value(A_USER_ID))
}
}
@Test
fun `present - a NotFound failure is exposed as such`() = runTest {
val error = HealthChatApiException.NotFound(null)
val directoryService = FakeHealthChatDirectoryService(
getMemberLambda = { Result.failure(error) },
)
val presenter = createPresenter(isCurrentUser = false, directoryService = directoryService)
presenter.test {
val state = consumeItemsUntilPredicate { it.profile is AsyncData.Failure }.last()
assertThat((state.profile as AsyncData.Failure).error).isEqualTo(error)
}
}
@Test
fun `present - a NotApproved failure is exposed as such`() = runTest {
val error = HealthChatApiException.NotApproved("Konto wird geprüft")
val directoryService = FakeHealthChatDirectoryService(
getMemberLambda = { Result.failure(error) },
)
val presenter = createPresenter(isCurrentUser = false, directoryService = directoryService)
presenter.test {
val state = consumeItemsUntilPredicate { it.profile is AsyncData.Failure }.last()
assertThat((state.profile as AsyncData.Failure).error).isEqualTo(error)
}
}
@Test
fun `present - Retry loads the member again after a failure`() = runTest {
val member = aHealthChatMember(userId = A_USER_ID)
var callCount = 0
val directoryService = FakeHealthChatDirectoryService(
getMemberLambda = {
callCount++
if (callCount == 1) {
Result.failure(HealthChatApiException.Network(IOException("no network")))
} else {
Result.success(member)
}
},
)
val presenter = createPresenter(isCurrentUser = false, directoryService = directoryService)
presenter.test {
val failedState = consumeItemsUntilPredicate { it.profile is AsyncData.Failure }.last()
failedState.eventSink(HealthChatProfileSectionEvents.Retry)
val state = consumeItemsUntilPredicate { it.profile is AsyncData.Success }.last()
assertThat(state.profile).isEqualTo(AsyncData.Success(member))
assertThat(callCount).isEqualTo(2)
}
}
private fun createPresenter(
isCurrentUser: Boolean,
userId: UserId = A_USER_ID,
directoryService: HealthChatDirectoryService = FakeHealthChatDirectoryService(),
profileRepository: HealthChatProfileRepository = FakeHealthChatProfileRepository(),
): HealthChatProfileSectionPresenter {
return HealthChatProfileSectionPresenter(
userId = userId,
isCurrentUser = isCurrentUser,
directoryService = directoryService,
profileRepository = profileRepository,
)
}
}
@@ -0,0 +1,154 @@
/*
* 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.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.healthchat.impl.profile.section
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
class HealthChatProfileSectionViewTest : RobolectricTest() {
@Test
fun `a loaded profile renders its fields with values`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>(expectEvents = false)
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Success(aProfileSectionMember()),
eventSink = eventsRecorder,
),
)
}
onNodeWithText("Allgemeinmedizin").assertExists()
onNodeWithText("Praxis Muster").assertExists()
onNodeWithText("Musterstrasse 12\n8001 Zürich").assertExists()
onNodeWithText("praxis@muster.ch").assertExists()
onNodeWithText("+41 44 123 45 67").assertExists()
onNodeWithText("https://praxis-muster.ch").assertExists()
onNodeWithText("FMH Allgemeine Innere Medizin").assertExists()
}
@Test
fun `empty fields are omitted`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>(expectEvents = false)
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Success(
aProfileSectionMember(
practiceName = null,
businessEmail = null,
businessTel = null,
street = null,
houseNumber = null,
postalCode = null,
businessCity = null,
website = null,
furtherInfo = null,
)
),
eventSink = eventsRecorder,
),
)
}
onNodeWithText("Allgemeinmedizin").assertExists()
onNodeWithText("Praxis Muster").assertDoesNotExist()
}
@Test
fun `clicking the email row opens a mailto intent`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>(expectEvents = false)
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Success(aProfileSectionMember()),
eventSink = eventsRecorder,
),
)
}
onNodeWithText("praxis@muster.ch").performClick()
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW)
assertThat(intent.data.toString()).isEqualTo("mailto:praxis@muster.ch")
}
@Test
fun `clicking the phone row opens a tel intent`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>(expectEvents = false)
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Success(aProfileSectionMember()),
eventSink = eventsRecorder,
),
)
}
onNodeWithText("+41 44 123 45 67").performClick()
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
assertThat(intent.dataString).isEqualTo("tel:+41 44 123 45 67")
}
@Test
fun `a loading state renders nothing`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>(expectEvents = false)
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Loading(),
eventSink = eventsRecorder,
),
)
}
onNodeWithText("Allgemeinmedizin").assertDoesNotExist()
}
@Test
fun `a NotFound failure renders nothing`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>(expectEvents = false)
val activity = requireNotNull(activity)
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Failure(HealthChatApiException.NotFound(null)),
eventSink = eventsRecorder,
),
)
}
onNodeWithText(activity.getString(CommonStrings.action_retry)).assertDoesNotExist()
}
@Test
fun `an error state shows a retry row which emits Retry`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<HealthChatProfileSectionEvents>()
setContent {
HealthChatProfileSectionView(
state = aHealthChatProfileSectionState(
profile = AsyncData.Failure(HealthChatApiException.Network(RuntimeException("boom"))),
eventSink = eventsRecorder,
),
)
}
clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(HealthChatProfileSectionEvents.Retry)
}
}
@@ -41,6 +41,8 @@ dependencies {
api(projects.features.roomdetails.api)
implementation(libs.coil.compose)
implementation(projects.features.call.api)
implementation(projects.features.healthchat.api)
implementation(projects.features.startchat.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.userprofile.shared)
implementation(projects.services.analytics.compose)
@@ -19,6 +19,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.HealthChatProfileSectionRenderer
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.libraries.architecture.NodeInputs
@@ -38,6 +39,8 @@ class RoomMemberDetailsNode(
private val analyticsService: AnalyticsService,
private val permalinkBuilder: PermalinkBuilder,
presenterFactory: RoomMemberDetailsPresenter.Factory,
// HealthChat fork: renders the extended profile fields.
private val healthChatProfileSectionRenderer: HealthChatProfileSectionRenderer,
) : Node(buildContext, plugins = plugins) {
data class RoomMemberDetailsInput(
val roomMemberId: UserId,
@@ -83,6 +86,9 @@ class RoomMemberDetailsNode(
onStartCall = ::onStartCall,
openAvatarPreview = callback::navigateToAvatarPreview,
onVerifyClick = callback::startVerifyUserFlow,
healthChatProfileSection = { userId, isCurrentUser ->
healthChatProfileSectionRenderer.Render(userId, isCurrentUser, Modifier)
},
)
}
}
@@ -35,6 +35,7 @@ dependencies {
implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.healthchat.api)
implementation(projects.features.verifysession.api)
api(projects.features.userprofile.api)
api(projects.features.userprofile.shared)
@@ -19,6 +19,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.HealthChatProfileSectionRenderer
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.libraries.architecture.NodeInputs
@@ -37,6 +38,8 @@ class UserProfileNode(
private val analyticsService: AnalyticsService,
private val permalinkBuilder: PermalinkBuilder,
presenterFactory: UserProfilePresenter.Factory,
// HealthChat fork: renders the extended profile fields.
private val healthChatProfileSectionRenderer: HealthChatProfileSectionRenderer,
) : Node(buildContext, plugins = plugins) {
data class UserProfileInputs(
val userId: UserId
@@ -78,6 +81,9 @@ class UserProfileNode(
onStartCall = callback::startCall,
openAvatarPreview = callback::navigateToAvatarPreview,
onVerifyClick = callback::startVerifyUserFlow,
healthChatProfileSection = { userId, isCurrentUser ->
healthChatProfileSectionRenderer.Render(userId, isCurrentUser, Modifier)
},
)
}
}
@@ -56,6 +56,8 @@ fun UserProfileView(
openAvatarPreview: (username: String, url: String) -> Unit,
onVerifyClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
// HealthChat fork: slot for the extended profile fields, rendered by features/healthchat.
healthChatProfileSection: @Composable (userId: UserId, isCurrentUser: Boolean) -> Unit = { _, _ -> },
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
@@ -93,6 +95,7 @@ fun UserProfileView(
onCall = { intent -> state.dmRoomId?.let { onStartCall(it, intent) } }
)
Spacer(modifier = Modifier.height(26.dp))
healthChatProfileSection(state.userId, state.isCurrentUser)
if (!state.isCurrentUser) {
VerifyUserSection(state, onVerifyClick = { onVerifyClick(state.userId) })
BlockUserSection(state)