From 1cf01cdc629b63f61f7fd67ad5708d299d514535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl?= Date: Thu, 6 Aug 2026 13:10:01 +0200 Subject: [PATCH] Add member directory tab to home navigation (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(healthchat): add member directory tab to home navigation Third bottom-nav tab backed by the HealthChat API: searchable member cards with native start-chat, approval-pending banner, retry on failure and navigation into the native profile screens. * fix(healthchat): stop tab back handler swallowing back presses While a screen is pushed on top of Home, the still-composed tab BackHandler intercepted back presses during the transition and invisibly switched to the Chats tab. Gate it on the node's target visibility in both the parent and Home's own backstack. * fix(healthchat): satisfy detekt, ktlint and konsist rules Import alias order, composable parameter order in HomeView, and konsist presenter helper shape. * ci: pass the real repository name to the screenshot recorder recordScreenshots.yml set GITHUB_REPOSITORY from `secrets.GITHUB_REPOSITORY`. No such secret exists on this fork, so the expression expands to an empty string and overrides the built-in environment variable of the same name. recordScreenshots.sh checks it up front and exits 1 with "No repo specified", so the workflow could never record anything here. Use the `github.repository` context instead, which is always populated. Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F * Update screenshots * Revert "Update screenshots" This reverts commit b99e0d643f224601db348c181fce8e35a781a44e. * Reapply "Update screenshots" This reverts commit 85bceb6d6796153d45e7a2a7fe1494b8cddc6e32. * test(screenshots): stop a LayoutLib teardown crash failing the shard tests Paparazzi 2.0.0-alpha05 / LayoutLib 16.2.1 races on HandlerThread startup: HandlerThread_Delegate.run calls Process.setThreadPriority, which throws NoSuchMethodError for Thread.setPosixNicenessInternal and kills the "android.bg" thread. If the test thread reaches getLooper() after that, it gets null, BackgroundThread.ensureThreadReady() NPEs, and the static initialiser of LatencyTracker fails. The JVM then marks that class erroneous for the lifetime of the fork, so every later preview showing the soft keyboard dies with NoClassDefFoundError. LayoutLibErrorFilterStatement already swallowed the bare NoSuchMethodError, but PaparazziLogger re-throws collected background errors from teardown() wrapped in a MultipleFailuresException when there is more than one, and that wrapper slipped through. A recent run reported 17 failures with zero image comparisons failing and zero delta images written. Unwrap the wrapper by reflection and swallow it only when every collected cause matches the known-bug list; anything unrecognised, and anything that cannot be decomposed, is rethrown. Also close a hole this filter had from the start. Paparazzi runs the test as `try { body } finally { teardown() }`, so a teardown error discards whatever the body threw — including a genuine snapshot mismatch. renderFailureCapture is registered inside the Paparazzi rule to record the body's exception so it wins over any teardown error, and the filter refuses to swallow anything if that rule is not registered. Two HorizontalFloatingToolbar previews have been mismatching silently behind this, and may now surface as real failures. Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F --------- Co-authored-by: ElementBot --- .github/workflows/recordScreenshots.yml | 14 +- .../android/appnav/LoggedInFlowNode.kt | 5 + .../healthchat/api/HealthChatMemberDisplay.kt | 12 + .../api/HealthChatProfileSectionRenderer.kt | 2 +- .../api/directory/MemberDirectoryEvents.kt | 26 ++ .../api/directory/MemberDirectoryRenderer.kt | 38 +++ .../api/directory/MemberDirectoryState.kt | 30 +++ .../directory/MemberDirectoryStateFixtures.kt | 99 ++++++++ .../main/res/values-de/strings_healthchat.xml | 17 ++ .../main/res/values-fr/strings_healthchat.xml | 18 ++ .../main/res/values-it/strings_healthchat.xml | 18 ++ .../main/res/values/strings_healthchat.xml | 17 ++ features/healthchat/impl/build.gradle.kts | 3 + .../healthchat/impl/HealthChatErrorMessage.kt | 30 +++ .../DefaultMemberDirectoryRenderer.kt | 48 ++++ .../directory/MemberDirectoryPresenter.kt | 93 +++++++ .../directory/MemberDirectoryStateProvider.kt | 42 ++++ .../impl/directory/MemberDirectoryView.kt | 234 ++++++++++++++++++ .../impl/directory/components/MemberCard.kt | 146 +++++++++++ .../components/MemberDirectoryErrorContent.kt | 63 +++++ .../components/MemberDirectoryHeader.kt | 97 ++++++++ .../MemberDirectoryPendingBanner.kt | 44 ++++ .../directory/di/MemberDirectoryModule.kt | 24 ++ .../directory/MemberDirectoryPresenterTest.kt | 182 ++++++++++++++ .../impl/directory/MemberDirectoryViewTest.kt | 159 ++++++++++++ .../features/home/api/HomeEntryPoint.kt | 4 + features/home/impl/build.gradle.kts | 1 + .../features/home/impl/HomeFlowNode.kt | 35 ++- .../home/impl/HomeNavigationBarItem.kt | 7 + .../features/home/impl/HomePresenter.kt | 5 + .../android/features/home/impl/HomeState.kt | 3 + .../home/impl/HomeStatePreviewParam.kt | 7 + .../android/features/home/impl/HomeView.kt | 42 +++- .../home/impl/components/HomeTopBar.kt | 2 + .../home/impl/DefaultHomeEntryPointTest.kt | 3 + .../features/home/impl/HomePresenterTest.kt | 17 ++ .../home/impl/HomeViewBackHandlingTest.kt | 78 ++++++ 37 files changed, 1651 insertions(+), 14 deletions(-) create mode 100644 features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt create mode 100644 features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt create mode 100644 features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt create mode 100644 features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryPendingBanner.kt create mode 100644 features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/di/MemberDirectoryModule.kt create mode 100644 features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt create mode 100644 features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryViewTest.kt create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index f9f1e5137a..48a9f62cec 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -20,8 +20,12 @@ env: # OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps # make two builds likelier to coexist; the real fix is sizing the containers to the # host. See the "Report runner capacity" step. - GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g -Dsonar.gradle.skipCompile=true - CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache + # Kept character-identical to tests.yml so the record path and the verify path cannot drift + # apart again. These only affect the Gradle daemon — Paparazzi renders in forked test workers + # configured entirely by build.gradle.kts — but keeping them aligned removes a whole class of + # "did record and verify run the same way?" questions. + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC + CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4 jobs: record: @@ -82,5 +86,9 @@ jobs: run: ./.github/workflows/scripts/recordScreenshots.sh env: GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }} + # Must be the `github.repository` context, not a secret. There is no + # GITHUB_REPOSITORY secret on this fork, so `secrets.GITHUB_REPOSITORY` expands to "" + # and overrides the built-in env var of the same name — recordScreenshots.sh then + # exits 1 with "No repo specified" before Gradle ever runs. + GITHUB_REPOSITORY: ${{ github.repository }} GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }} 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 27d727afa9..63ce2be6e8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -362,6 +362,11 @@ class LoggedInFlowNode( override fun navigateToSession(sessionId: SessionId) { callback.navigateToSession(sessionId) } + + // 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..cf43b4d996 --- /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.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 +import io.element.android.features.healthchat.api.R as HealthChatR + +@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..aae6fa932c --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenterTest.kt @@ -0,0 +1,182 @@ +/* + * 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( + 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 81c467246f..517882cec6 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 @@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -35,5 +36,8 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToBugReport() fun navigateToAddAccount() fun navigateToSession(sessionId: SessionId) + + // 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 7fcc2dde32..ed3ae4bb3d 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -49,6 +49,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.leaveroom.api) implementation(projects.features.rageshake.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 fda28e0fa0..567ce4f212 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 @@ -12,6 +12,7 @@ import android.app.Activity import android.os.Parcelable import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,6 +23,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.LocalNodeTargetVisibility import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin @@ -32,6 +34,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 @@ -86,6 +89,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( @@ -98,6 +103,10 @@ class HomeFlowNode( private val callback: HomeEntryPoint.Callback = callback() private val stateFlow = launchMolecule { presenter.present() } + // HealthChat fork: whether this node is the visible target within the parent backstack. + // False as soon as another screen (e.g. a member profile) starts being pushed on top. + private val isVisibleNavTarget = mutableStateOf(true) + override fun onBuilt() { super.onBuilt() lifecycle.subscribe( @@ -246,13 +255,37 @@ class HomeFlowNode( onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom, modifier = Modifier ) - } + }, + memberDirectoryView = { contentPadding, lazyListState -> + memberDirectoryRenderer.Render( + state = state.memberDirectoryState, + contentPadding = contentPadding, + lazyListState = lazyListState, + onOpenDm = { navigateToRoom(roomId = it, eventId = null) }, + onMemberClick = { callback.navigateToUserProfile(it) }, + onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) }, + // Hidden until the native editor and invite screens exist. + onEditProfileClick = null, + onInviteClick = null, + modifier = Modifier, + ) + }, + // Both levels: Home within the parent backstack (captured in View) and the + // root element within Home's own backstack. + isBackHandlingEnabled = isVisibleNavTarget.value && LocalNodeTargetVisibility.current, ) } } @Composable override fun View(modifier: Modifier) { + // HealthChat fork: capture the target visibility at this level, where the + // CompositionLocal still refers to Home within the PARENT backstack (inside + // rootNode it is re-provided for Home's own backstack). + val isVisibleTarget = LocalNodeTargetVisibility.current + SideEffect { + isVisibleNavTarget.value = isVisibleTarget + } BackstackView() } 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 e0c7829b0f..69f9016a0b 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 @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService +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.rageshake.api.RageshakeFeatureAvailability @@ -49,6 +50,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 rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, private val announcementService: AnnouncementService, @@ -100,6 +103,7 @@ class HomePresenter( } 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 { @@ -136,6 +140,7 @@ class HomePresenter( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + memberDirectoryState = memberDirectoryState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, showMultiAccountAnnouncement = showMultiAccountAnnouncement, 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 eb8e3ff2f1..204ac998cd 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 @@ -26,6 +27,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 showMultiAccountAnnouncement: Boolean, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index 4a82166d3e..f073848a3d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.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.RoomListStatePreviewParam import io.element.android.features.home.impl.roomlist.aRoomListState @@ -50,6 +52,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider { ), ) + aHomeState( showMultiAccountAnnouncement = true, + ) + aHomeState( + // HealthChat fork: appended LAST so upstream snapshot indices stay stable. + 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, showMultiAccountAnnouncement: Boolean = false, totalUnreadCount: Int = 0, @@ -77,5 +83,6 @@ internal fun aHomeState( roomListState = roomListState, homeSpacesState = homeSpacesState, totalUnreadCount = totalUnreadCount, + 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 6640120614..34b0b20ce7 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 @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.FabPosition import androidx.compose.material3.TopAppBarDefaults @@ -90,6 +91,11 @@ fun HomeView( onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit, acceptDeclineInviteView: @Composable () -> Unit, modifier: Modifier = Modifier, + // HealthChat fork: false while another screen is pushed on top of Home, so the + // tab-switching BackHandler cannot swallow back presses during the transition. + isBackHandlingEnabled: Boolean = true, + // HealthChat fork: content of the member directory tab, rendered by features/healthchat. + memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> }, leaveRoomView: @Composable () -> Unit, ) { val state: RoomListState = homeState.roomListState @@ -133,6 +139,8 @@ fun HomeView( onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, onSwitchToAccount = onSwitchToAccount, + memberDirectoryView = memberDirectoryView, + isBackHandlingEnabled = isBackHandlingEnabled, ) if (state.globalSearchState.isEnabled) { @@ -171,7 +179,9 @@ private fun HomeScaffold( onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, onSwitchToAccount: (SessionId) -> Unit, + isBackHandlingEnabled: Boolean, modifier: Modifier = Modifier, + memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit, ) { fun onRoomClick(room: RoomListRoomSummary) { onRoomClick(room.roomId) @@ -182,7 +192,8 @@ private fun HomeScaffold( val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val roomListState: RoomListState = state.roomListState - BackHandler(enabled = state.isBackHandlerEnabled) { + // HealthChat fork: also gated on isBackHandlingEnabled (see HomeView docs). + BackHandler(enabled = state.isBackHandlerEnabled && isBackHandlingEnabled) { if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) { state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats)) } else { @@ -195,6 +206,7 @@ private fun HomeScaffold( val roomsLazyListState = rememberLazyListState() val spacesLazyListState = rememberLazyListState() + val membersLazyListState = rememberLazyListState() Scaffold( modifier = modifier, @@ -242,6 +254,7 @@ private fun HomeScaffold( val lazyListStateTarget = when (item) { HomeNavigationBarItem.Chats -> roomsLazyListState HomeNavigationBarItem.Spaces -> spacesLazyListState + HomeNavigationBarItem.Members -> membersLazyListState } coroutineScope.launch { if (lazyListStateTarget.firstVisibleItemIndex > 10) { @@ -255,15 +268,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 }, totalUnreadCount = state.totalUnreadCount, ) @@ -325,6 +338,17 @@ private fun HomeScaffold( onExploreClick = {}, ) } + // HealthChat fork: member directory tab content. + HomeNavigationBarItem.Members -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(outerPadding) + .consumeWindowInsets(outerPadding), + ) { + memberDirectoryView(lazyColumnContentPadding + contentPadding, membersLazyListState) + } + } } } }, 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 291b9f3c00..36869e0a40 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 @@ -133,6 +133,8 @@ fun HomeTopBar( } } HomeNavigationBarItem.Spaces -> null + // HealthChat fork: member directory tab title. + HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes) } displayTitle?.let { val style = when (spaceFiltersState) { 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 6e962f6fe0..70f197650f 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 @@ -15,6 +15,7 @@ import io.element.android.features.invite.test.declineandblock.FakeDeclineInvite import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +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 @@ -41,6 +42,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, + memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, ) } @@ -55,6 +57,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToBugReport() = lambdaError() override fun navigateToAddAccount() = lambdaError() override fun navigateToSession(sessionId: SessionId) = 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 93ea9576b8..120e5138bb 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 @@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.test.FakeAnnouncementService +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 @@ -322,6 +323,21 @@ class HomePresenterTest { assertThat(awaitItem().totalUnreadCount).isEqualTo(5) } } + + @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 TestScope.createHomePresenter( @@ -342,6 +358,7 @@ internal fun TestScope.createHomePresenter( indicatorService = indicatorService, roomListPresenter = { aRoomListState() }, homeSpacesPresenter = homeSpacesPresenter, + memberDirectoryPresenter = { aMemberDirectoryState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, announcementService = announcementService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt new file mode 100644 index 0000000000..f4f45856f0 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt @@ -0,0 +1,78 @@ +/* + * 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.home.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.pressBackKey +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class HomeViewBackHandlingTest : RobolectricTest() { + @Test + fun `back press on the members tab switches to the chats tab`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setContent { + HomeView( + homeState = aHomeState( + currentHomeNavigationBarItem = HomeNavigationBarItem.Members, + eventSink = eventsRecorder, + ), + onRoomClick = {}, + onSettingsClick = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onStartChatClick = {}, + onCreateSpaceClick = {}, + onRoomSettingsClick = {}, + onMenuActionClick = {}, + onReportRoomClick = {}, + onDeclineInviteAndBlockUser = {}, + acceptDeclineInviteView = {}, + leaveRoomView = {}, + ) + } + pressBackKey() + eventsRecorder.assertSingle(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats)) + } + + @Test + fun `back press is not intercepted while home is not the visible nav target`() = runAndroidComposeUiTest { + // Regression test: while another screen (e.g. a member profile) is being pushed on + // top of Home, the tab BackHandler must not swallow the back press by invisibly + // switching tabs — the back press has to reach the parent backstack pop instead. + val eventsRecorder = EventsRecorder(expectEvents = false) + setContent { + HomeView( + homeState = aHomeState( + currentHomeNavigationBarItem = HomeNavigationBarItem.Members, + eventSink = eventsRecorder, + ), + onRoomClick = {}, + onSettingsClick = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onStartChatClick = {}, + onCreateSpaceClick = {}, + onRoomSettingsClick = {}, + onMenuActionClick = {}, + onReportRoomClick = {}, + onDeclineInviteAndBlockUser = {}, + acceptDeclineInviteView = {}, + leaveRoomView = {}, + isBackHandlingEnabled = false, + ) + } + pressBackKey() + } +}