mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +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:
+28
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+6
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user