diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 14230d7c5a..d8109a198a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -373,6 +373,11 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + // HealthChat fork: open a user profile from the member directory tab. + override fun navigateToUserProfile(userId: UserId) { + backstack.push(NavTarget.UserProfile(userId)) + } } homeEntryPoint.createNode( parentNode = this, diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt index df1c894e13..02814fe4bd 100644 --- a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt @@ -8,6 +8,8 @@ package io.element.android.features.healthchat.api +import io.element.android.libraries.matrix.api.user.MatrixUser + /** * Display name fallback chain, mirroring the web client: * displayName → "title firstName lastName" → businessEmail → [fallback]. @@ -21,3 +23,13 @@ fun HealthChatMember.bestDisplayName(fallback: String): String { businessEmail?.takeIf { it.isNotBlank() }?.let { return it } return fallback } + +/** + * The Matrix representation of a directory member, e.g. to start a DM through the app's + * regular flows. + */ +fun HealthChatMember.toMatrixUser() = MatrixUser( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, +) diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt index 971c459af4..ceef8b0c66 100644 --- a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.core.UserId * 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 { +fun interface HealthChatProfileSectionRenderer { @Composable fun Render( userId: UserId, diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt new file mode 100644 index 0000000000..0025442928 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt @@ -0,0 +1,26 @@ +/* + * 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.directory + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface MemberDirectoryEvents { + /** + * Fired by the view on first composition. The tab presenter runs from app start (it is + * composed by the Home presenter), so the first fetch is deferred until the tab is shown. + */ + data object LoadIfNeeded : MemberDirectoryEvents + + /** Header refresh and "try again" — one load path. */ + data object Refresh : MemberDirectoryEvents + + data class StartDm(val matrixUser: MatrixUser) : MemberDirectoryEvents + + data object ClearStartDmState : MemberDirectoryEvents +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt new file mode 100644 index 0000000000..3cbcc3293d --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt @@ -0,0 +1,38 @@ +/* + * 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.directory + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Renders the member directory tab content. Injected into the Home screen as a slot so the + * fork's upstream diff stays minimal. + * + * Nullable callbacks hide their affordance entirely (e.g. profile editing and inviting only + * become available once the corresponding screens exist). + */ +fun interface MemberDirectoryRenderer { + @Composable + fun Render( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + onOpenDm: (RoomId) -> Unit, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier, + ) +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt new file mode 100644 index 0000000000..0cfa553669 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt @@ -0,0 +1,30 @@ +/* + * 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.directory + +import androidx.compose.foundation.text.input.TextFieldState +import io.element.android.features.healthchat.api.HealthChatMemberDirectory +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId + +data class MemberDirectoryState( + val searchQuery: TextFieldState, + val directory: AsyncData, + val startDmActionState: AsyncAction, + val eventSink: (MemberDirectoryEvents) -> Unit, +) { + /** Unknown-while-loading counts as approved so no banner flashes during the first load. */ + val isApproved: Boolean + get() = directory.dataOrNull()?.isApproved != false + + /** The verbatim German server explanation, only when the account is not approved. */ + val pendingMessage: String? + get() = directory.dataOrNull()?.takeIf { !it.isApproved }?.pendingMessage +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt new file mode 100644 index 0000000000..c826f0a8f5 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.api.directory + +import androidx.compose.foundation.text.input.TextFieldState +import io.element.android.features.healthchat.api.HealthChatMember +import io.element.android.features.healthchat.api.HealthChatMemberDirectory +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.toImmutableList + +/** + * Sample state builders, shared by previews inside the healthchat feature and by the Home + * screen's previews (same pattern as `aDirectLogoutState` in `features/logout/api`). + */ +fun aMemberDirectoryState( + searchQuery: TextFieldState = TextFieldState(), + directory: AsyncData = AsyncData.Success(aMemberDirectory()), + startDmActionState: AsyncAction = AsyncAction.Uninitialized, + eventSink: (MemberDirectoryEvents) -> Unit = {}, +) = MemberDirectoryState( + searchQuery = searchQuery, + directory = directory, + startDmActionState = startDmActionState, + eventSink = eventSink, +) + +fun aMemberDirectory( + members: List = aMemberList(), + totalCount: Int = members.size, + searchQuery: String = "", + isApproved: Boolean = true, + pendingMessage: String? = null, +) = HealthChatMemberDirectory( + members = members.toImmutableList(), + totalCount = totalCount, + searchQuery = searchQuery, + isApproved = isApproved, + pendingMessage = pendingMessage, +) + +fun aMemberList(): List = listOf( + aDirectoryMember(), + aDirectoryMember( + userId = UserId("@luca.bianchi:healthchat.ch"), + displayName = "Dr. med. Luca Bianchi", + specialty = "Kardiologie", + practiceName = "Herzzentrum Bellevue", + businessCity = "Bern", + businessEmail = null, + businessTel = null, + ), + aDirectoryMember( + userId = UserId("@nora.keller:healthchat.ch"), + displayName = null, + specialty = null, + practiceName = null, + businessCity = null, + businessEmail = "nora.keller@example.ch", + businessTel = null, + ), +) + +fun aDirectoryMember( + 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", + isApproved: Boolean = true, +) = HealthChatMember( + userId = userId, + displayName = displayName, + avatarUrl = null, + title = null, + firstName = null, + lastName = null, + specialty = specialty, + practiceName = practiceName, + businessCity = businessCity, + furtherInfo = null, + businessEmail = businessEmail, + businessTel = businessTel, + street = null, + houseNumber = null, + postalCode = null, + website = null, + isApproved = isApproved, +) diff --git a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml index 3e6e8159cb..ec6ffe282b 100644 --- a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml @@ -20,4 +20,21 @@ Weitere Infos / Qualifikationen Die HealthChat-Profildaten konnten nicht geladen werden. Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist. + Mitglieder + + %1$d Mitglied + %1$d Mitglieder + + Mein Profil + Suche nach Name, Praxis, Ort oder Fachgebiet + Chat starten + Fehler beim Erstellen des Chats + Es sind noch keine Mitglieder vorhanden. + Keine Mitglieder entsprechen Ihrer Suche. + Unbekannt + Konto wird geprüft + Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen. + Aktualisieren + Profil bearbeiten + Neues Mitglied einladen diff --git a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml index 55ea83ac9c..49df77d036 100644 --- a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml @@ -20,4 +20,22 @@ Informations complémentaires / qualifications Les détails du profil HealthChat n’ont pas pu être chargés. Les détails du profil seront visibles dès que votre compte aura été approuvé. + Membres + + %1$d membre + %1$d membres + %1$d membres + + Mon profil + Rechercher par nom, cabinet, ville ou spécialité + Démarrer une discussion + Impossible de démarrer la discussion + Il n’y a pas encore de membres. + Aucun membre ne correspond à votre recherche. + Inconnu + Compte en cours de vérification + Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres. + Actualiser + Modifier le profil + Inviter un nouveau membre diff --git a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml index 31ffbf5810..20af35a02b 100644 --- a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml @@ -20,4 +20,22 @@ Ulteriori informazioni / qualifiche Impossibile caricare i dettagli del profilo HealthChat. I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato. + Membri + + %1$d membro + %1$d membri + %1$d membri + + Il mio profilo + Cerca per nome, studio, città o specializzazione + Avvia chat + Impossibile avviare la chat + Non ci sono ancora membri. + Nessun membro corrisponde alla tua ricerca. + Sconosciuto + Account in fase di verifica + Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri. + Aggiorna + Modifica profilo + Invita un nuovo membro diff --git a/features/healthchat/api/src/main/res/values/strings_healthchat.xml b/features/healthchat/api/src/main/res/values/strings_healthchat.xml index 7de9716380..f17fbea476 100644 --- a/features/healthchat/api/src/main/res/values/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values/strings_healthchat.xml @@ -21,4 +21,21 @@ Further info / qualifications The HealthChat profile details couldn’t be loaded. Profile details become visible once your account has been approved. + Members + + %1$d member + %1$d members + + My profile + Search by name, practice, city or specialty + Start chat + Failed to start the chat + There are no members yet. + No members match your search. + Unknown + Account under review + Your account is currently under review. Once it has been approved you can see and search all members. + Refresh + Edit profile + Invite a new member diff --git a/features/healthchat/impl/build.gradle.kts b/features/healthchat/impl/build.gradle.kts index cea0f71299..7042791b93 100644 --- a/features/healthchat/impl/build.gradle.kts +++ b/features/healthchat/impl/build.gradle.kts @@ -44,6 +44,7 @@ setupDependencyInjection() dependencies { api(projects.features.healthchat.api) implementation(libs.coroutines.core) + implementation(projects.features.startchat.api) implementation(platform(libs.network.okhttp.bom)) implementation(libs.network.okhttp.okhttp) implementation(libs.network.okhttp.logging) @@ -56,6 +57,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.network) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) @@ -63,6 +65,7 @@ dependencies { testCommonDependencies(libs, true) testImplementation(libs.network.mockwebserver) testImplementation(projects.features.healthchat.test) + testImplementation(projects.features.startchat.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.preferences.api) testImplementation(projects.libraries.preferences.test) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt new file mode 100644 index 0000000000..10a6a6980f --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatStrings + +/** + * User-facing message for a HealthChat failure: the verbatim German server message where one + * exists, localized fallbacks for local/unknown failures. + */ +@Composable +fun Throwable.healthChatUserMessage(): String { + return when (this) { + is HealthChatApiException.Network -> stringResource(HealthChatStrings.healthchat_error_network) + is HealthChatApiException.Unauthorized -> serverMessage + is HealthChatApiException.NotApproved -> serverMessage + is HealthChatApiException.NotFound -> serverMessage + is HealthChatApiException.Http -> serverMessage + else -> null + } ?: stringResource(HealthChatStrings.healthchat_error_unknown) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt new file mode 100644 index 0000000000..492401dd88 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt @@ -0,0 +1,48 @@ +/* + * 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.directory + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesBinding(SessionScope::class) +class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer { + @Composable + override fun Render( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + onOpenDm: (RoomId) -> Unit, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier, + ) { + MemberDirectoryView( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + onOpenDm = onOpenDm, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + modifier = modifier, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt new file mode 100644 index 0000000000..031aea6c21 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt @@ -0,0 +1,93 @@ +/* + * 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.directory + +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.healthchat.api.HealthChatDirectoryService +import io.element.android.features.healthchat.api.HealthChatMemberDirectory +import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Inject +class MemberDirectoryPresenter( + private val directoryService: HealthChatDirectoryService, + private val startDMAction: StartDMAction, +) : Presenter { + @Composable + override fun present(): MemberDirectoryState { + val coroutineScope = rememberCoroutineScope() + val searchQuery = rememberTextFieldState() + // This presenter is composed from app start (by the Home presenter), so the first + // fetch is deferred until the tab is actually shown (LoadIfNeeded from the view). + var hasBeenRequested by rememberSaveable { mutableStateOf(false) } + var loadTrigger by rememberSaveable { mutableIntStateOf(0) } + var directory by remember { mutableStateOf>(AsyncData.Uninitialized) } + val startDmActionState = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + LaunchedEffect(hasBeenRequested, searchQuery.text, loadTrigger) { + if (!hasBeenRequested) return@LaunchedEffect + if (searchQuery.text.isNotEmpty()) { + // Effect restart on typing = debounce. + delay(300.milliseconds) + } + directory = AsyncData.Loading(directory.dataOrNull()) + directoryService.searchMembers(searchQuery.text.toString()).fold( + onSuccess = { directory = AsyncData.Success(it) }, + // No stale fallback: a failed (re)load always shows the error state. + onFailure = { directory = AsyncData.Failure(it) }, + ) + } + + fun handleEvents(event: MemberDirectoryEvents) { + when (event) { + MemberDirectoryEvents.LoadIfNeeded -> { + hasBeenRequested = true + } + MemberDirectoryEvents.Refresh -> { + loadTrigger++ + } + is MemberDirectoryEvents.StartDm -> coroutineScope.launch { + startDMAction.execute( + matrixUser = event.matrixUser, + createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming, + actionState = startDmActionState, + ) + } + MemberDirectoryEvents.ClearStartDmState -> { + startDmActionState.value = AsyncAction.Uninitialized + } + } + } + + return MemberDirectoryState( + searchQuery = searchQuery, + directory = directory, + startDmActionState = startDmActionState.value, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt new file mode 100644 index 0000000000..f59e175d2d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.directory + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.directory.aMemberDirectory +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState +import io.element.android.libraries.architecture.AsyncData + +open class MemberDirectoryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMemberDirectoryState(), + aMemberDirectoryState(directory = AsyncData.Uninitialized), + aMemberDirectoryState(directory = AsyncData.Loading(aMemberDirectory())), + aMemberDirectoryState( + directory = AsyncData.Failure( + HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer") + ), + ), + aMemberDirectoryState( + directory = AsyncData.Success( + aMemberDirectory( + members = emptyList(), + isApproved = false, + pendingMessage = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.", + ) + ), + ), + aMemberDirectoryState( + directory = AsyncData.Success(aMemberDirectory(members = emptyList())), + ), + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt new file mode 100644 index 0000000000..4af5dc9b99 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt @@ -0,0 +1,234 @@ +/* + * 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.directory + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.toMatrixUser +import io.element.android.features.healthchat.impl.directory.components.MemberCard +import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryErrorContent +import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryHeader +import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryPendingBanner +import io.element.android.features.healthchat.impl.healthChatUserMessage +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.SearchField +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun MemberDirectoryView( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + onOpenDm: (RoomId) -> Unit, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + state.eventSink(MemberDirectoryEvents.LoadIfNeeded) + } + Box(modifier = modifier.fillMaxSize()) { + when (val directory = state.directory) { + AsyncData.Uninitialized -> FullScreenLoading() + is AsyncData.Loading -> if (directory.prevData == null) { + FullScreenLoading() + } else { + MemberDirectoryContent( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + isRefreshing = true, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + ) + } + is AsyncData.Failure -> MemberDirectoryErrorContent( + message = directory.error.healthChatUserMessage(), + onRetry = { state.eventSink(MemberDirectoryEvents.Refresh) }, + ) + is AsyncData.Success -> MemberDirectoryContent( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + isRefreshing = false, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + ) + } + AsyncActionView( + async = state.startDmActionState, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_starting_chat), + ) + }, + onSuccess = onOpenDm, + errorMessage = { stringResource(HealthChatStrings.healthchat_screen_member_directory_error_starting_chat) }, + onRetry = null, + onErrorDismiss = { state.eventSink(MemberDirectoryEvents.ClearStartDmState) }, + confirmationDialog = { data -> + if (data is ConfirmingStartDmWithMatrixUser) { + CreateDmConfirmationBottomSheet( + matrixUser = data.matrixUser, + isUserIdentityUnknown = data.isUserIdentityUnknown, + onSendInvite = { + state.eventSink(MemberDirectoryEvents.StartDm(data.matrixUser)) + }, + onDismiss = { + state.eventSink(MemberDirectoryEvents.ClearStartDmState) + }, + ) + } + }, + ) + } +} + +@Composable +private fun MemberDirectoryContent( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + isRefreshing: Boolean, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val directory = state.directory.dataOrNull() ?: return + Column(modifier = modifier.fillMaxSize()) { + if (isRefreshing) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize(), + ) { + item(key = "header") { + MemberDirectoryHeader( + memberCount = directory.totalCount, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = if (state.isApproved) onInviteClick else null, + onRefreshClick = { state.eventSink(MemberDirectoryEvents.Refresh) }, + ) + } + if (!state.isApproved) { + item(key = "pending") { + MemberDirectoryPendingBanner( + message = state.pendingMessage + ?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message), + ) + } + } else { + item(key = "search") { + SearchField( + state = state.searchQuery, + placeholder = stringResource(HealthChatStrings.healthchat_screen_member_directory_search_placeholder), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + if (directory.members.isEmpty()) { + item(key = "empty") { + val message = if (state.searchQuery.text.isNotEmpty()) { + stringResource(HealthChatStrings.healthchat_screen_member_directory_empty_search) + } else { + stringResource(HealthChatStrings.healthchat_screen_member_directory_empty) + } + Text( + text = message, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + ) + } + } + items(directory.members, key = { it.userId.value }) { member -> + MemberCard( + member = member, + onClick = onMemberClick?.let { callback -> { callback(member.userId) } }, + onStartChatClick = { state.eventSink(MemberDirectoryEvents.StartDm(member.toMatrixUser())) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + } +} + +@Composable +private fun FullScreenLoading( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@PreviewsDayNight +@Composable +internal fun MemberDirectoryViewPreview( + @PreviewParameter(MemberDirectoryStateProvider::class) state: MemberDirectoryState +) = ElementPreview { + MemberDirectoryView( + state = state, + contentPadding = PaddingValues(), + lazyListState = rememberLazyListState(), + onOpenDm = {}, + onMemberClick = {}, + onMyProfileClick = {}, + onEditProfileClick = null, + onInviteClick = null, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt new file mode 100644 index 0000000000..14bddb5298 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +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.HealthChatMember +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.api.bestDisplayName +import io.element.android.features.healthchat.api.directory.aDirectoryMember +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun MemberCard( + member: HealthChatMember, + onClick: (() -> Unit)?, + onStartChatClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .let { base -> onClick?.let { base.clickable(onClick = it) } ?: base }, + color = ElementTheme.colors.bgSubtleSecondary, + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val displayName = member.bestDisplayName( + fallback = stringResource(HealthChatStrings.healthchat_screen_member_directory_unknown_name), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar( + avatarData = AvatarData( + id = member.userId.value, + name = displayName, + url = member.avatarUrl, + size = AvatarSize.UserPreference, + ), + avatarType = AvatarType.User, + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = displayName, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + member.specialty?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textActionAccent, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + member.practiceName?.let { MemberCardDetailRow(icon = CompoundIcons.Company(), text = it) } + member.businessCity?.let { MemberCardDetailRow(icon = CompoundIcons.LocationPin(), text = it) } + member.businessEmail?.let { MemberCardDetailRow(icon = CompoundIcons.Email(), text = it) } + member.businessTel?.let { MemberCardDetailRow(icon = CompoundIcons.VoiceCall(), text = it) } + Text( + text = member.userId.value, + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton( + text = stringResource(HealthChatStrings.healthchat_screen_member_directory_start_chat), + onClick = onStartChatClick, + size = ButtonSize.Small, + ) + } + } +} + +@Composable +private fun MemberCardDetailRow( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier.size(16.dp), + ) + Text( + text = text, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MemberCardPreview() = ElementPreview { + MemberCard( + member = aDirectoryMember(), + onClick = {}, + onStartChatClick = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt new file mode 100644 index 0000000000..0e6e70392d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt @@ -0,0 +1,63 @@ +/* + * 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.directory.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun MemberDirectoryErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + BigIcon(style = BigIcon.Style.AlertSolid) + Text( + text = message, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Button( + text = stringResource(CommonStrings.action_retry), + onClick = onRetry, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MemberDirectoryErrorContentPreview() = ElementPreview { + MemberDirectoryErrorContent( + message = "Fehler beim Laden der Benutzer", + onRetry = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt new file mode 100644 index 0000000000..0a398bd3c6 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt @@ -0,0 +1,97 @@ +/* + * 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.directory.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +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.HealthChatStrings +import io.element.android.features.healthchat.api.R as HealthChatR +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton + +@Composable +fun MemberDirectoryHeader( + memberCount: Int, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + onRefreshClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = pluralStringResource( + HealthChatR.plurals.healthchat_screen_member_directory_member_count, + memberCount, + memberCount, + ), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.weight(1f), + ) + onMyProfileClick?.let { + TextButton( + text = stringResource(HealthChatStrings.healthchat_screen_member_directory_my_profile), + onClick = it, + ) + } + onEditProfileClick?.let { + IconButton(onClick = it) { + Icon( + imageVector = CompoundIcons.Edit(), + contentDescription = stringResource(HealthChatStrings.healthchat_a11y_edit_profile), + ) + } + } + onInviteClick?.let { + IconButton(onClick = it) { + Icon( + imageVector = CompoundIcons.UserAdd(), + contentDescription = stringResource(HealthChatStrings.healthchat_a11y_invite_member), + ) + } + } + IconButton(onClick = onRefreshClick) { + Icon( + imageVector = CompoundIcons.Restart(), + contentDescription = stringResource(HealthChatStrings.healthchat_a11y_refresh), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MemberDirectoryHeaderPreview() = ElementPreview { + MemberDirectoryHeader( + memberCount = 42, + onMyProfileClick = {}, + onEditProfileClick = null, + onInviteClick = null, + onRefreshClick = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt new file mode 100644 index 0000000000..acd462da39 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt @@ -0,0 +1,44 @@ +/* + * 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.directory.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@Composable +fun MemberDirectoryPendingBanner( + message: String, + modifier: Modifier = Modifier, +) { + Announcement( + title = stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_title), + description = message, + type = AnnouncementType.Informative(), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +@PreviewsDayNight +@Composable +internal fun MemberDirectoryPendingBannerPreview() = ElementPreview { + MemberDirectoryPendingBanner( + message = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.", + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt new file mode 100644 index 0000000000..a3d004f052 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt @@ -0,0 +1,24 @@ +/* + * 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.directory.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.impl.directory.MemberDirectoryPresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@BindingContainer +@ContributesTo(SessionScope::class) +interface MemberDirectoryModule { + @Binds + fun bindMemberDirectoryPresenter(presenter: MemberDirectoryPresenter): Presenter +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt new file mode 100644 index 0000000000..d4d9529e70 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt @@ -0,0 +1,184 @@ +/* + * 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.directory + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.MutableState +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.HealthChatMemberDirectory +import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents +import io.element.android.features.healthchat.test.FakeHealthChatDirectoryService +import io.element.android.features.healthchat.test.aHealthChatMember +import io.element.android.features.invitepeople.test.FakeStartDMAction +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_ROOM_ID +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.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +class MemberDirectoryPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - nothing is fetched before LoadIfNeeded`() = runTest { + val searchLambda = lambdaRecorder> { + Result.success(aDirectory()) + } + val presenter = createPresenter( + directoryService = FakeHealthChatDirectoryService(searchMembersLambda = { searchLambda(it) }), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.directory).isEqualTo(AsyncData.Uninitialized) + searchLambda.assertions().isNeverCalled() + initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded) + val loadedState = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last() + assertThat(loadedState.directory.dataOrNull()).isEqualTo(aDirectory()) + searchLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - typing a search query triggers a debounced server side search`() = runTest { + val queries = mutableListOf() + val presenter = createPresenter( + directoryService = FakeHealthChatDirectoryService( + searchMembersLambda = { query -> + queries.add(query) + Result.success(aDirectory(searchQuery = query)) + }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded) + val loadedState = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last() + // Typing restarts the effect: intermediate values must be debounced away. + loadedState.searchQuery.setTextAndPlaceCursorAtEnd("M") + loadedState.searchQuery.setTextAndPlaceCursorAtEnd("Mü") + loadedState.searchQuery.setTextAndPlaceCursorAtEnd("Müller") + consumeItemsUntilPredicate { it.directory.dataOrNull()?.searchQuery == "Müller" } + assertThat(queries).containsExactly("", "Müller").inOrder() + } + } + + @Test + fun `present - a failure is exposed and Refresh recovers`() = runTest { + var callCount = 0 + val presenter = createPresenter( + directoryService = FakeHealthChatDirectoryService( + searchMembersLambda = { + callCount++ + if (callCount == 1) { + Result.failure(HealthChatApiException.Network(IOException("no network"))) + } else { + Result.success(aDirectory()) + } + }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded) + val failedState = consumeItemsUntilPredicate { it.directory is AsyncData.Failure }.last() + // No stale fallback. + assertThat((failedState.directory as AsyncData.Failure).prevData).isNull() + failedState.eventSink(MemberDirectoryEvents.Refresh) + val loadedState = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last() + assertThat(loadedState.directory.dataOrNull()).isEqualTo(aDirectory()) + } + } + + @Test + fun `present - an unapproved account exposes the pending message and disables search`() = runTest { + val presenter = createPresenter( + directoryService = FakeHealthChatDirectoryService( + searchMembersLambda = { + Result.success( + aDirectory( + members = persistentListOf(), + isApproved = false, + pendingMessage = "Ihr Konto wird derzeit geprüft.", + ) + ) + }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MemberDirectoryEvents.LoadIfNeeded) + val state = consumeItemsUntilPredicate { it.directory is AsyncData.Success }.last() + assertThat(state.isApproved).isFalse() + assertThat(state.pendingMessage).isEqualTo("Ihr Konto wird derzeit geprüft.") + } + } + + @Test + fun `present - StartDm delegates to the StartDMAction with the given user`() = runTest { + val executeLambda = lambdaRecorder { matrixUser: MatrixUser, createIfDmDoesNotExist: Boolean, actionState: MutableState> -> + assertThat(matrixUser.userId).isEqualTo(A_USER_ID) + assertThat(createIfDmDoesNotExist).isFalse() + actionState.value = AsyncAction.Success(A_ROOM_ID) + } + val presenter = createPresenter( + directoryService = FakeHealthChatDirectoryService(searchMembersLambda = { Result.success(aDirectory()) }), + startDMAction = FakeStartDMAction(executeResult = { user, create, actionState -> + executeLambda(user, create, actionState) + }), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MemberDirectoryEvents.StartDm(MatrixUser(A_USER_ID))) + val state = consumeItemsUntilPredicate { it.startDmActionState is AsyncAction.Success }.last() + assertThat((state.startDmActionState as AsyncAction.Success).data).isEqualTo(A_ROOM_ID) + executeLambda.assertions().isCalledOnce() + state.eventSink(MemberDirectoryEvents.ClearStartDmState) + val clearedState = consumeItemsUntilPredicate { it.startDmActionState is AsyncAction.Uninitialized }.last() + assertThat(clearedState.startDmActionState).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun aDirectory( + members: List = listOf(aHealthChatMember()), + searchQuery: String = "", + isApproved: Boolean = true, + pendingMessage: String? = null, + ) = HealthChatMemberDirectory( + members = persistentListOf(*members.toTypedArray()), + totalCount = members.size, + searchQuery = searchQuery, + isApproved = isApproved, + pendingMessage = pendingMessage, + ) + + private fun createPresenter( + directoryService: HealthChatDirectoryService = FakeHealthChatDirectoryService(), + startDMAction: StartDMAction = FakeStartDMAction(), + ): MemberDirectoryPresenter { + return MemberDirectoryPresenter( + directoryService = directoryService, + startDMAction = startDMAction, + ) + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt new file mode 100644 index 0000000000..89d88c57d3 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt @@ -0,0 +1,159 @@ +/* + * 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.directory + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.rememberLazyListState +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 io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.directory.aDirectoryMember +import io.element.android.features.healthchat.api.directory.aMemberDirectory +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState +import io.element.android.features.healthchat.api.toMatrixUser +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class MemberDirectoryViewTest : RobolectricTest() { + @Test + fun `on composition LoadIfNeeded is emitted`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setMemberDirectoryView( + state = aMemberDirectoryState( + directory = AsyncData.Uninitialized, + eventSink = eventsRecorder, + ), + ) + eventsRecorder.assertSingle(MemberDirectoryEvents.LoadIfNeeded) + } + + @Test + fun `clicking retry on the error state emits Refresh`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setMemberDirectoryView( + state = aMemberDirectoryState( + directory = AsyncData.Failure(HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer")), + eventSink = eventsRecorder, + ), + ) + // The verbatim German server message is shown. + onNodeWithText("Fehler beim Laden der Benutzer").assertExists() + clickOn(CommonStrings.action_retry) + eventsRecorder.assertList( + listOf( + MemberDirectoryEvents.LoadIfNeeded, + MemberDirectoryEvents.Refresh, + ) + ) + } + + @Test + fun `clicking start chat emits StartDm with the member as MatrixUser`() = runAndroidComposeUiTest { + val member = aDirectoryMember() + val eventsRecorder = EventsRecorder() + setMemberDirectoryView( + state = aMemberDirectoryState( + directory = AsyncData.Success(aMemberDirectory(members = listOf(member))), + eventSink = eventsRecorder, + ), + ) + val activity = requireNotNull(activity) + onNodeWithText(activity.getString(io.element.android.features.healthchat.api.R.string.healthchat_screen_member_directory_start_chat)) + .performClick() + eventsRecorder.assertList( + listOf( + MemberDirectoryEvents.LoadIfNeeded, + MemberDirectoryEvents.StartDm(member.toMatrixUser()), + ) + ) + } + + @Test + fun `clicking a member card opens the profile`() = runAndroidComposeUiTest { + val member = aDirectoryMember() + val eventsRecorder = EventsRecorder(expectEvents = true) + ensureCalledOnceWithParam(member.userId) { callback -> + setMemberDirectoryView( + state = aMemberDirectoryState( + directory = AsyncData.Success(aMemberDirectory(members = listOf(member))), + eventSink = eventsRecorder, + ), + onMemberClick = callback, + ) + onNodeWithText("Dr. med. Erika Muster").performClick() + } + } + + @Test + fun `an unapproved account shows the pending banner and no search field`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = true) + setMemberDirectoryView( + state = aMemberDirectoryState( + directory = AsyncData.Success( + aMemberDirectory( + members = emptyList(), + isApproved = false, + pendingMessage = "Ihr Konto wird derzeit geprüft.", + ) + ), + eventSink = eventsRecorder, + ), + ) + onNodeWithText("Ihr Konto wird derzeit geprüft.").assertExists() + val activity = requireNotNull(activity) + onNodeWithText(activity.getString(io.element.android.features.healthchat.api.R.string.healthchat_screen_member_directory_search_placeholder)) + .assertDoesNotExist() + } + + @Test + fun `hidden affordances are not rendered when their callbacks are null`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = true) + setMemberDirectoryView( + state = aMemberDirectoryState(eventSink = eventsRecorder), + onMyProfileClick = null, + ) + val activity = requireNotNull(activity) + onNodeWithText(activity.getString(io.element.android.features.healthchat.api.R.string.healthchat_screen_member_directory_my_profile)) + .assertDoesNotExist() + } +} + +@OptIn(ExperimentalTestApi::class) +private fun androidx.compose.ui.test.AndroidComposeUiTest.setMemberDirectoryView( + state: MemberDirectoryState, + onMemberClick: ((UserId) -> Unit)? = EnsureNeverCalledWithParam(), + onMyProfileClick: (() -> Unit)? = {}, +) { + setContent { + MemberDirectoryView( + state = state, + contentPadding = PaddingValues(), + lazyListState = rememberLazyListState(), + onOpenDm = {}, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = null, + onInviteClick = null, + ) + } +} diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1a90245611..0886b186ec 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -31,5 +32,8 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + + // HealthChat fork: open a user profile from the member directory tab. + fun navigateToUserProfile(userId: UserId) } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index efdc545e61..44448fc6ad 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.features.announcement.api) + implementation(projects.features.healthchat.api) implementation(projects.features.invite.api) implementation(projects.features.networkmonitor.api) implementation(projects.features.logout.api) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 00f285e3f4..aad998b3c1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -32,6 +32,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.directory.MemberDirectoryRenderer import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.model.RoomListRoomSummary @@ -87,6 +88,8 @@ class HomeFlowNode( private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val leaveRoomRenderer: LeaveRoomRenderer, + // HealthChat fork: renders the member directory tab. + private val memberDirectoryRenderer: MemberDirectoryRenderer, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : BaseFlowNode( backstack = BackStack( @@ -244,7 +247,21 @@ class HomeFlowNode( onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom, modifier = Modifier ) - } + }, + memberDirectoryView = { contentPadding, lazyListState -> + memberDirectoryRenderer.Render( + state = state.memberDirectoryState, + contentPadding = contentPadding, + lazyListState = lazyListState, + onOpenDm = ::navigateToRoom, + onMemberClick = { callback.navigateToUserProfile(it) }, + onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) }, + // Hidden until the native editor and invite screens exist. + onEditProfileClick = null, + onInviteClick = null, + modifier = Modifier, + ) + }, ) directLogoutView.Render(state.directLogoutState) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt index 035e953b23..8ca102eec0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt @@ -11,6 +11,7 @@ package io.element.android.features.home.impl import androidx.annotation.StringRes import androidx.compose.runtime.Composable import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.healthchat.api.HealthChatStrings enum class HomeNavigationBarItem( @StringRes @@ -21,6 +22,11 @@ enum class HomeNavigationBarItem( ), Spaces( labelRes = R.string.screen_home_tab_spaces + ), + + // HealthChat fork: member directory tab. Keep as LAST entry so persisted ordinals stay stable. + Members( + labelRes = HealthChatStrings.healthchat_screen_home_tab_members ); @Composable @@ -29,6 +35,7 @@ enum class HomeNavigationBarItem( ) = when (this) { Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat() Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space() + Members -> if (isSelected) CompoundIcons.UserProfileSolid() else CompoundIcons.UserProfile() } companion object { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 1e49b1aa70..ece767e711 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.healthchat.api.directory.MemberDirectoryState import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.logout.api.direct.DirectLogoutState @@ -42,6 +43,8 @@ class HomePresenter( private val indicatorService: IndicatorService, private val roomListPresenter: Presenter, private val homeSpacesPresenter: Presenter, + // HealthChat fork: member directory tab presenter. + private val memberDirectoryPresenter: Presenter, private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, @@ -63,6 +66,7 @@ class HomePresenter( val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() + val memberDirectoryState = memberDirectoryPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } val currentHomeNavigationBarItem by remember { derivedStateOf { @@ -96,6 +100,7 @@ class HomePresenter( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + memberDirectoryState = memberDirectoryState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, directLogoutState = directLogoutState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index ae59ef8eb9..5852f6d969 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -8,6 +8,7 @@ package io.element.android.features.home.impl +import io.element.android.features.healthchat.api.directory.MemberDirectoryState import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spaces.HomeSpacesState @@ -27,6 +28,8 @@ data class HomeState( val currentHomeNavigationBarItem: HomeNavigationBarItem, val roomListState: RoomListState, val homeSpacesState: HomeSpacesState, + // HealthChat fork: state of the member directory tab. + val memberDirectoryState: MemberDirectoryState, val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, val directLogoutState: DirectLogoutState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt index ea9ff7a234..19c1f6e185 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt @@ -9,6 +9,8 @@ package io.element.android.features.home.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListStateProvider import io.element.android.features.home.impl.roomlist.aRoomListState @@ -50,6 +52,9 @@ open class HomeStateProvider : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + // HealthChat fork: appended LAST so upstream snapshot indices stay stable. + ) + aHomeState( + currentHomeNavigationBarItem = HomeNavigationBarItem.Members, ) } @@ -62,6 +67,7 @@ internal fun aHomeState( currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats, roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), + memberDirectoryState: MemberDirectoryState = aMemberDirectoryState(), canReportBug: Boolean = true, directLogoutState: DirectLogoutState = aDirectLogoutState(), eventSink: (HomeEvent) -> Unit = {} @@ -75,5 +81,6 @@ internal fun aHomeState( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + memberDirectoryState = memberDirectoryState, eventSink = eventSink, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6956ba6ba5..9c4932cf92 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -87,6 +88,8 @@ fun HomeView( acceptDeclineInviteView: @Composable () -> Unit, modifier: Modifier = Modifier, leaveRoomView: @Composable () -> Unit, + // HealthChat fork: content of the member directory tab, rendered by features/healthchat. + memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> }, ) { val state: RoomListState = homeState.roomListState val coroutineScope = rememberCoroutineScope() @@ -121,6 +124,7 @@ fun HomeView( onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, + memberDirectoryView = memberDirectoryView, ) // This overlaid view will only be visible when state.displaySearchResults is true RoomListSearchView( @@ -147,6 +151,7 @@ private fun HomeScaffold( onStartChatClick: () -> Unit, onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, + memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit, modifier: Modifier = Modifier, ) { fun onRoomClick(room: RoomListRoomSummary) { @@ -172,6 +177,7 @@ private fun HomeScaffold( val hazeState = rememberHazeState() val roomsLazyListState = rememberLazyListState() val spacesLazyListState = rememberLazyListState() + val membersLazyListState = rememberLazyListState() Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -208,6 +214,7 @@ private fun HomeScaffold( val lazyListStateTarget = when (item) { HomeNavigationBarItem.Chats -> roomsLazyListState HomeNavigationBarItem.Spaces -> spacesLazyListState + HomeNavigationBarItem.Members -> membersLazyListState } coroutineScope.launch { if (lazyListStateTarget.firstVisibleItemIndex > 10) { @@ -221,15 +228,15 @@ private fun HomeScaffold( state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item)) } }, - floatingActionButton = { - when (state.currentHomeNavigationBarItem) { - HomeNavigationBarItem.Chats -> { - HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) - } - HomeNavigationBarItem.Spaces -> { - HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) - } + floatingActionButton = when (state.currentHomeNavigationBarItem) { + HomeNavigationBarItem.Chats -> { + { HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) } } + HomeNavigationBarItem.Spaces -> { + { HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) } + } + // HealthChat fork: the member directory has no create action. + HomeNavigationBarItem.Members -> null }, ) }, @@ -285,6 +292,18 @@ private fun HomeScaffold( onExploreClick = {}, ) } + // HealthChat fork: member directory tab content. + HomeNavigationBarItem.Members -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .hazeSource(state = hazeState), + ) { + memberDirectoryView(contentPadding, membersLazyListState) + } + } } }, snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index a563520e09..d29d23e603 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -116,6 +116,8 @@ fun HomeTopBar( } } HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes) + // HealthChat fork: member directory tab title. + HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes) } Text( modifier = Modifier.semantics { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 8eee8e1ee2..d58bd7acb9 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.home.api.HomeEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.services.analytics.test.FakeAnalyticsService @@ -39,6 +40,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, + memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, ) } @@ -51,6 +53,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToEnterRecoveryKey() = lambdaError() override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() + override fun navigateToUserProfile(userId: UserId) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 371a718523..2f2ec442b0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState @@ -145,6 +146,21 @@ class HomePresenterTest { assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) } } + + @Test + fun `present - NavigationBar change to Members`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Members)) + val finalState = awaitItem() + assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Members) + } + } } internal fun createHomePresenter( @@ -162,6 +178,7 @@ internal fun createHomePresenter( indicatorService = indicatorService, roomListPresenter = { aRoomListState() }, homeSpacesPresenter = homeSpacesPresenter, + memberDirectoryPresenter = { aMemberDirectoryState() }, logoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Day_0_en.png new file mode 100644 index 0000000000..32f58ea975 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f6389635e4bf31d05b63fffea64bf4e031a0772f08d63ea99a2f093afec7577 +size 28208 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Night_0_en.png new file mode 100644 index 0000000000..50ce2922b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33b68ade07f7c6c17d0d781f30523412b42a6804114b101af91096c1c746473a +size 27928 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Day_0_en.png new file mode 100644 index 0000000000..681b313aa9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425603e1c99ed96fd58c6dc4bf17436135f42fc130af9cba031fdd7abcebdaf3 +size 12001 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Night_0_en.png new file mode 100644 index 0000000000..9570abc393 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb075c9bf32a98df531cc5ab5baac6d5b2073e264e701ac3cab1fc4329d36b8 +size 11975 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Day_0_en.png new file mode 100644 index 0000000000..e30b6aedcc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5f89026226cc16350afa1ddaf2c79370912804645b0880639204ab0fb7cabed +size 8751 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Night_0_en.png new file mode 100644 index 0000000000..486cf93c59 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aae4d33c3a26f3f974795f21583463af868240d6e5831096b15f0440dc5a8cb +size 8384 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Day_0_en.png new file mode 100644 index 0000000000..a9188968c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54bc768b15b03d7aa359237f94a0c581f793743299eac42e69984edf47d4c407 +size 22908 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Night_0_en.png new file mode 100644 index 0000000000..111939486a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c55cd38c77016f63d1d58abb601c383eabf06bb33589c0b78524489f1ad81e57 +size 22033 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_0_en.png new file mode 100644 index 0000000000..d187a00939 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4372d95814bf2893940b9568b33d317e57efe8d2e1b1b4d31f13ba63a8b7a24 +size 62499 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_1_en.png new file mode 100644 index 0000000000..e3e5480add --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e73b036458ee32e207f711cf6656fe7646b23d3d9e096e62932c828dd53189 +size 5244 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_2_en.png new file mode 100644 index 0000000000..91ef97d5f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8601efbcbb55c49be61521dbea2878975be82e0c967a1ab763b9d79a307513c8 +size 62731 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_3_en.png new file mode 100644 index 0000000000..681b313aa9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425603e1c99ed96fd58c6dc4bf17436135f42fc130af9cba031fdd7abcebdaf3 +size 12001 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_4_en.png new file mode 100644 index 0000000000..bb32ab1a28 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bc85d1d728dc5406dbed71d0694eb6e3f155dd6611c9ef5f13c09bf4fb9c8d7 +size 30349 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_5_en.png new file mode 100644 index 0000000000..2d5e9a39fb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68890517e79280bcf737b5b142e5149d3df5d905c416026cb221e50f269314ce +size 22427 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_0_en.png new file mode 100644 index 0000000000..c3884f1f9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f647a882a0f17d7bb2015fced96fc7b0dc62e12fea7db5e6e1624a331ba2edb3 +size 61112 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_1_en.png new file mode 100644 index 0000000000..6582264383 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518818c549548b6304d2960242ce7251bb609fa439928539a7556c33223ca8ba +size 5251 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_2_en.png new file mode 100644 index 0000000000..09ce8a0e8b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95b55f3d720f68f22cf3f7a0bb0243343f251a99bee544c8372d7bee9dabcc6a +size 61267 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_3_en.png new file mode 100644 index 0000000000..9570abc393 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb075c9bf32a98df531cc5ab5baac6d5b2073e264e701ac3cab1fc4329d36b8 +size 11975 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_4_en.png new file mode 100644 index 0000000000..ff17e81aa5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7dc9b0cd64526244a8c79acbc943a79a44b92a3fc5852509151b83c7d377281 +size 29198 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_5_en.png new file mode 100644 index 0000000000..6228e0bdfd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:caeaf6ef97c06b9a104a998aadf801a3dc503e6cb151e5a326f48dae36d54204 +size 21420 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png index 344bd350d6..25b763efb0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5dde8ed5c776f4701f24dd7d411dd78f48adab261efad71e961c845e24d12f3 -size 144367 +oid sha256:1c6c46d1907618c3c93de1cc18109c13748297e7d6eb853061185c658d807ab7 +size 145743 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png index f106244405..5f3dc374ba 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c393b13532fba4d4ef25dda45b6ca3e1f6da6d49c5b2de412beded261a4f9311 -size 36164 +oid sha256:8aa0b89a8027f48e475297605640861805782129d4aa6434b4db0115a3e11997 +size 37341 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png index f2a69ea475..86a5f48ea4 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b493bc5151f15d6ba91bfb0c27b41f3abcf13ee7d511aa59db171cecdbd95f3 -size 31958 +oid sha256:e2948c30cb3ac117b376b4e2ac0358c21a4961a69181f245e302891f4b3c3a93 +size 33767 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png index f4dff45422..c2d9207799 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a33681fe064d5e12f53273bfd9262af8c7b902e237cb8d0641573b0645e291c -size 94687 +oid sha256:2e8250d679bf891c8ba0aac9b75b896e9cb51573dc5744f6c06918328e5bd06d +size 96132 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png index bdad242551..d4a58bd22c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:022954bdca89572b74bc70f6d91820e99eef8826409c0a06e6145a5c3ec8eaf4 -size 87268 +oid sha256:f55e99dc15e3d6e6de35a4963ec5daebb4ae7951381c2ac068ae558f7321244d +size 89124 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png index edf07a66f3..abddbab3ef 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af36d0e3383935d256b3730e6f955e858ae42a82f54485b879e7a6a71fc25f6c -size 55237 +oid sha256:07b0a251e29984fcbad6d3a60d4ff803e2c2fe9f2572112af9b39686f011bb93 +size 56352 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png index aa313049f5..4e7726b79f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e55407ac7116b3127a979f630777d0d528a349c38d37829d8bc0c37a316c7c5b -size 42526 +oid sha256:f66ad2e82f0d6a532ceb21f6e870993b88e3724877a56c4cd51fb1f9ad1b1810 +size 43683 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png new file mode 100644 index 0000000000..8715d77372 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a195d78b1a37e196d21d9dae74d0b3a86c9f553333ed7fd67041e89a7b1e92a1 +size 21184 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png index e01335907a..01d36a5ca2 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25e5ac35ddf79fdc78c21dbd031f506da04caac7bdd53f279c88fd4574f72a82 -size 64644 +oid sha256:67fffcd83343ed27d94be8f00d0ae6d831c254210825c5368692bc71f84dbab7 +size 65929 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png index fe41a54fcc..4f9adb7665 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c1245da0a4cf56aba8e102e7f297996ed58fc8c50165288dea11ca86fbc0dcc -size 57235 +oid sha256:d16c165b825131579a42cd11642d66403d66901b6b2f717054e482b3d560ce3b +size 58272 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png index b6779aca93..6798cdbc1c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0769e243dc60de52c16df23a86eb62167bb60cb756d113f88b9b476c025e402b -size 87117 +oid sha256:2a637d4c8b06fac2126e9d9ad8d6cc2bafa320736bb63d84f00ec747fd9ffdaf +size 88975 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png index 420cb7c631..259a52a65c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d56c1af9fc73f4aaeed33dd8b6113ec64ddbfdf63506aa31ae6996732870baa2 -size 31127 +oid sha256:3b56caa6b22f3b196ffa0aa3f1078e50e8279a34ce80a7a80f3520fab3bd1686 +size 32168 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png index f36b5c2f83..da3b5737bb 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c3fb8ae530989e5a6d6e4193fbac4d4356af34076b684b782a0f8fec31226f1 -size 26657 +oid sha256:34f0ee3e98c2a9dd2f4a2d7f6f0ea882065a76aa1f13244ad089b5f845dff6d1 +size 27623 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png index 48b07fc9bd..a449b2df1c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d302be6dbaef08b501f4f33d3e01338e068767702a7018bd5bb24ade6eee2cb -size 88558 +oid sha256:3b62578b9e60a843095f86bd044f8d002abb6ae2d1060f31e91d0ac9a4352805 +size 89341 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png index 5cb7d766cd..8dd0458515 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6be5cd35eec917bf1c96d03079df8235d0bc7b52159f54c8a177a64f648e58c2 -size 81212 +oid sha256:148cdf26fd055c0247fc8e64a927e01c5881145f376e98223a3f558282629a7a +size 82151 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png index 2691d1c1df..cbc0f7a7b1 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d241a4270bef0a1f80c62c302cb5b40b4370f73e8175a746d32c90d1f98208 -size 49882 +oid sha256:9cb7fb35d7b6d091679ee928cec3a7b521d6c34425b9ad7e6b49e372e9ac99b3 +size 50856 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png index 451ca0c3ec..5beb60984f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1838a9839badf62d4d427a66a15a7d220996bdb0b60f0373249d62e4afae2910 -size 37390 +oid sha256:b07834c3eb7226d88ab19576d29e1b0b7efeb6b2c04fbaa45a7411e507108d81 +size 38388 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png new file mode 100644 index 0000000000..f44be3935f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6cce1b807bcaea11d702c24fd5341dd1fe615461bc3bfe68b8dec3a8b0f3e03 +size 17275 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png index 89c4934dd9..333e652846 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8172ca867a8c2e827ead36a22cefe967a5b57c69cae6d5aca1c941e4dd2ba16f -size 60160 +oid sha256:824e9f229ecb3ccfc4c8600238126e7b1793f1ca82171065db48ffa8e6d5a4eb +size 60823 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png index 390c9f6818..3306a382c3 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13740b4fc4cbd24075606e45bf070a8fbb762d298857bc071399def91dda7175 -size 52189 +oid sha256:face1048515f0f05f06ddcdd922397d15458c5eb2a1773fd853986b21380f497 +size 53053 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png index 1092dcd1b9..2ca6fbb8d8 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bce0bc8590037705369735e83e576f0818f4528298b4b2face999c8802f855b9 -size 81081 +oid sha256:17838682d1f7ced43bca749e32a9fbcf7ec9d286f72d868013b81ca422e30c07 +size 82039