mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
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:
@@ -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)
|
||||
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+77
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
)
|
||||
+85
@@ -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,
|
||||
)
|
||||
+212
@@ -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)
|
||||
}
|
||||
+129
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+154
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user