Merge pull request #2 from Novaloop-AG/feature/healthchat-profile-fields

Show HealthChat profile fields on profile screens
This commit is contained in:
2026-08-05 15:24:31 +02:00
committed by GitHub
31 changed files with 842 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,127 @@
/*
* 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(
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)
}
}
@@ -47,6 +47,7 @@ dependencies {
api(projects.services.apperror.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)
@@ -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)
},
)
}
}
@@ -37,6 +37,7 @@ dependencies {
implementation(projects.libraries.featureflag.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)
},
)
}
}
@@ -58,6 +58,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(
@@ -94,6 +96,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)