diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index 260fd9253e..09f72ea707 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 14230d7c5a..d8109a198a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -373,6 +373,11 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + // HealthChat fork: open a user profile from the member directory tab. + override fun navigateToUserProfile(userId: UserId) { + backstack.push(NavTarget.UserProfile(userId)) + } } homeEntryPoint.createNode( parentNode = this, diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt index df1c894e13..02814fe4bd 100644 --- a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt @@ -8,6 +8,8 @@ package io.element.android.features.healthchat.api +import io.element.android.libraries.matrix.api.user.MatrixUser + /** * Display name fallback chain, mirroring the web client: * displayName → "title firstName lastName" → businessEmail → [fallback]. @@ -21,3 +23,13 @@ fun HealthChatMember.bestDisplayName(fallback: String): String { businessEmail?.takeIf { it.isNotBlank() }?.let { return it } return fallback } + +/** + * The Matrix representation of a directory member, e.g. to start a DM through the app's + * regular flows. + */ +fun HealthChatMember.toMatrixUser() = MatrixUser( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, +) diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt index 971c459af4..ceef8b0c66 100644 --- a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileSectionRenderer.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.core.UserId * Injected into the upstream profile screens as a slot so the fork's only upstream diff is * one defaulted parameter plus the injection (same pattern as `InvitePeopleRenderer`). */ -interface HealthChatProfileSectionRenderer { +fun interface HealthChatProfileSectionRenderer { @Composable fun Render( userId: UserId, diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt new file mode 100644 index 0000000000..0025442928 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.api.directory + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface MemberDirectoryEvents { + /** + * Fired by the view on first composition. The tab presenter runs from app start (it is + * composed by the Home presenter), so the first fetch is deferred until the tab is shown. + */ + data object LoadIfNeeded : MemberDirectoryEvents + + /** Header refresh and "try again" — one load path. */ + data object Refresh : MemberDirectoryEvents + + data class StartDm(val matrixUser: MatrixUser) : MemberDirectoryEvents + + data object ClearStartDmState : MemberDirectoryEvents +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt new file mode 100644 index 0000000000..3cbcc3293d --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryRenderer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.api.directory + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Renders the member directory tab content. Injected into the Home screen as a slot so the + * fork's upstream diff stays minimal. + * + * Nullable callbacks hide their affordance entirely (e.g. profile editing and inviting only + * become available once the corresponding screens exist). + */ +fun interface MemberDirectoryRenderer { + @Composable + fun Render( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + onOpenDm: (RoomId) -> Unit, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier, + ) +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt new file mode 100644 index 0000000000..0cfa553669 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.api.directory + +import androidx.compose.foundation.text.input.TextFieldState +import io.element.android.features.healthchat.api.HealthChatMemberDirectory +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId + +data class MemberDirectoryState( + val searchQuery: TextFieldState, + val directory: AsyncData, + val startDmActionState: AsyncAction, + val eventSink: (MemberDirectoryEvents) -> Unit, +) { + /** Unknown-while-loading counts as approved so no banner flashes during the first load. */ + val isApproved: Boolean + get() = directory.dataOrNull()?.isApproved != false + + /** The verbatim German server explanation, only when the account is not approved. */ + val pendingMessage: String? + get() = directory.dataOrNull()?.takeIf { !it.isApproved }?.pendingMessage +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt new file mode 100644 index 0000000000..c826f0a8f5 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/directory/MemberDirectoryStateFixtures.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.api.directory + +import androidx.compose.foundation.text.input.TextFieldState +import io.element.android.features.healthchat.api.HealthChatMember +import io.element.android.features.healthchat.api.HealthChatMemberDirectory +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.toImmutableList + +/** + * Sample state builders, shared by previews inside the healthchat feature and by the Home + * screen's previews (same pattern as `aDirectLogoutState` in `features/logout/api`). + */ +fun aMemberDirectoryState( + searchQuery: TextFieldState = TextFieldState(), + directory: AsyncData = AsyncData.Success(aMemberDirectory()), + startDmActionState: AsyncAction = AsyncAction.Uninitialized, + eventSink: (MemberDirectoryEvents) -> Unit = {}, +) = MemberDirectoryState( + searchQuery = searchQuery, + directory = directory, + startDmActionState = startDmActionState, + eventSink = eventSink, +) + +fun aMemberDirectory( + members: List = aMemberList(), + totalCount: Int = members.size, + searchQuery: String = "", + isApproved: Boolean = true, + pendingMessage: String? = null, +) = HealthChatMemberDirectory( + members = members.toImmutableList(), + totalCount = totalCount, + searchQuery = searchQuery, + isApproved = isApproved, + pendingMessage = pendingMessage, +) + +fun aMemberList(): List = listOf( + aDirectoryMember(), + aDirectoryMember( + userId = UserId("@luca.bianchi:healthchat.ch"), + displayName = "Dr. med. Luca Bianchi", + specialty = "Kardiologie", + practiceName = "Herzzentrum Bellevue", + businessCity = "Bern", + businessEmail = null, + businessTel = null, + ), + aDirectoryMember( + userId = UserId("@nora.keller:healthchat.ch"), + displayName = null, + specialty = null, + practiceName = null, + businessCity = null, + businessEmail = "nora.keller@example.ch", + businessTel = null, + ), +) + +fun aDirectoryMember( + userId: UserId = UserId("@erika.muster:healthchat.ch"), + displayName: String? = "Dr. med. Erika Muster", + specialty: String? = "Allgemeinmedizin", + practiceName: String? = "Praxis Muster", + businessCity: String? = "Zürich", + businessEmail: String? = "praxis@muster.ch", + businessTel: String? = "+41 44 123 45 67", + isApproved: Boolean = true, +) = HealthChatMember( + userId = userId, + displayName = displayName, + avatarUrl = null, + title = null, + firstName = null, + lastName = null, + specialty = specialty, + practiceName = practiceName, + businessCity = businessCity, + furtherInfo = null, + businessEmail = businessEmail, + businessTel = businessTel, + street = null, + houseNumber = null, + postalCode = null, + website = null, + isApproved = isApproved, +) diff --git a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml index 3e6e8159cb..ec6ffe282b 100644 --- a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml @@ -20,4 +20,21 @@ Weitere Infos / Qualifikationen Die HealthChat-Profildaten konnten nicht geladen werden. Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist. + Mitglieder + + %1$d Mitglied + %1$d Mitglieder + + Mein Profil + Suche nach Name, Praxis, Ort oder Fachgebiet + Chat starten + Fehler beim Erstellen des Chats + Es sind noch keine Mitglieder vorhanden. + Keine Mitglieder entsprechen Ihrer Suche. + Unbekannt + Konto wird geprüft + Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen. + Aktualisieren + Profil bearbeiten + Neues Mitglied einladen diff --git a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml index 55ea83ac9c..49df77d036 100644 --- a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml @@ -20,4 +20,22 @@ Informations complémentaires / qualifications Les détails du profil HealthChat n’ont pas pu être chargés. Les détails du profil seront visibles dès que votre compte aura été approuvé. + Membres + + %1$d membre + %1$d membres + %1$d membres + + Mon profil + Rechercher par nom, cabinet, ville ou spécialité + Démarrer une discussion + Impossible de démarrer la discussion + Il n’y a pas encore de membres. + Aucun membre ne correspond à votre recherche. + Inconnu + Compte en cours de vérification + Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres. + Actualiser + Modifier le profil + Inviter un nouveau membre diff --git a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml index 31ffbf5810..20af35a02b 100644 --- a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml @@ -20,4 +20,22 @@ Ulteriori informazioni / qualifiche Impossibile caricare i dettagli del profilo HealthChat. I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato. + Membri + + %1$d membro + %1$d membri + %1$d membri + + Il mio profilo + Cerca per nome, studio, città o specializzazione + Avvia chat + Impossibile avviare la chat + Non ci sono ancora membri. + Nessun membro corrisponde alla tua ricerca. + Sconosciuto + Account in fase di verifica + Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri. + Aggiorna + Modifica profilo + Invita un nuovo membro diff --git a/features/healthchat/api/src/main/res/values/strings_healthchat.xml b/features/healthchat/api/src/main/res/values/strings_healthchat.xml index 7de9716380..f17fbea476 100644 --- a/features/healthchat/api/src/main/res/values/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values/strings_healthchat.xml @@ -21,4 +21,21 @@ Further info / qualifications The HealthChat profile details couldn’t be loaded. Profile details become visible once your account has been approved. + Members + + %1$d member + %1$d members + + My profile + Search by name, practice, city or specialty + Start chat + Failed to start the chat + There are no members yet. + No members match your search. + Unknown + Account under review + Your account is currently under review. Once it has been approved you can see and search all members. + Refresh + Edit profile + Invite a new member diff --git a/features/healthchat/impl/build.gradle.kts b/features/healthchat/impl/build.gradle.kts index cea0f71299..7042791b93 100644 --- a/features/healthchat/impl/build.gradle.kts +++ b/features/healthchat/impl/build.gradle.kts @@ -44,6 +44,7 @@ setupDependencyInjection() dependencies { api(projects.features.healthchat.api) implementation(libs.coroutines.core) + implementation(projects.features.startchat.api) implementation(platform(libs.network.okhttp.bom)) implementation(libs.network.okhttp.okhttp) implementation(libs.network.okhttp.logging) @@ -56,6 +57,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.network) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) @@ -63,6 +65,7 @@ dependencies { testCommonDependencies(libs, true) testImplementation(libs.network.mockwebserver) testImplementation(projects.features.healthchat.test) + testImplementation(projects.features.startchat.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.preferences.api) testImplementation(projects.libraries.preferences.test) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt new file mode 100644 index 0000000000..10a6a6980f --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatErrorMessage.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatStrings + +/** + * User-facing message for a HealthChat failure: the verbatim German server message where one + * exists, localized fallbacks for local/unknown failures. + */ +@Composable +fun Throwable.healthChatUserMessage(): String { + return when (this) { + is HealthChatApiException.Network -> stringResource(HealthChatStrings.healthchat_error_network) + is HealthChatApiException.Unauthorized -> serverMessage + is HealthChatApiException.NotApproved -> serverMessage + is HealthChatApiException.NotFound -> serverMessage + is HealthChatApiException.Http -> serverMessage + else -> null + } ?: stringResource(HealthChatStrings.healthchat_error_unknown) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt new file mode 100644 index 0000000000..492401dd88 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesBinding(SessionScope::class) +class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer { + @Composable + override fun Render( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + onOpenDm: (RoomId) -> Unit, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier, + ) { + MemberDirectoryView( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + onOpenDm = onOpenDm, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + modifier = modifier, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt new file mode 100644 index 0000000000..031aea6c21 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryPresenter.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory + +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.healthchat.api.HealthChatDirectoryService +import io.element.android.features.healthchat.api.HealthChatMemberDirectory +import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Inject +class MemberDirectoryPresenter( + private val directoryService: HealthChatDirectoryService, + private val startDMAction: StartDMAction, +) : Presenter { + @Composable + override fun present(): MemberDirectoryState { + val coroutineScope = rememberCoroutineScope() + val searchQuery = rememberTextFieldState() + // This presenter is composed from app start (by the Home presenter), so the first + // fetch is deferred until the tab is actually shown (LoadIfNeeded from the view). + var hasBeenRequested by rememberSaveable { mutableStateOf(false) } + var loadTrigger by rememberSaveable { mutableIntStateOf(0) } + var directory by remember { mutableStateOf>(AsyncData.Uninitialized) } + val startDmActionState = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + LaunchedEffect(hasBeenRequested, searchQuery.text, loadTrigger) { + if (!hasBeenRequested) return@LaunchedEffect + if (searchQuery.text.isNotEmpty()) { + // Effect restart on typing = debounce. + delay(300.milliseconds) + } + directory = AsyncData.Loading(directory.dataOrNull()) + directoryService.searchMembers(searchQuery.text.toString()).fold( + onSuccess = { directory = AsyncData.Success(it) }, + // No stale fallback: a failed (re)load always shows the error state. + onFailure = { directory = AsyncData.Failure(it) }, + ) + } + + fun handleEvents(event: MemberDirectoryEvents) { + when (event) { + MemberDirectoryEvents.LoadIfNeeded -> { + hasBeenRequested = true + } + MemberDirectoryEvents.Refresh -> { + loadTrigger++ + } + is MemberDirectoryEvents.StartDm -> coroutineScope.launch { + startDMAction.execute( + matrixUser = event.matrixUser, + createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming, + actionState = startDmActionState, + ) + } + MemberDirectoryEvents.ClearStartDmState -> { + startDmActionState.value = AsyncAction.Uninitialized + } + } + } + + return MemberDirectoryState( + searchQuery = searchQuery, + directory = directory, + startDmActionState = startDmActionState.value, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt new file mode 100644 index 0000000000..f59e175d2d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.directory.aMemberDirectory +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState +import io.element.android.libraries.architecture.AsyncData + +open class MemberDirectoryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMemberDirectoryState(), + aMemberDirectoryState(directory = AsyncData.Uninitialized), + aMemberDirectoryState(directory = AsyncData.Loading(aMemberDirectory())), + aMemberDirectoryState( + directory = AsyncData.Failure( + HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer") + ), + ), + aMemberDirectoryState( + directory = AsyncData.Success( + aMemberDirectory( + members = emptyList(), + isApproved = false, + pendingMessage = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.", + ) + ), + ), + aMemberDirectoryState( + directory = AsyncData.Success(aMemberDirectory(members = emptyList())), + ), + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt new file mode 100644 index 0000000000..4af5dc9b99 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/MemberDirectoryView.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.toMatrixUser +import io.element.android.features.healthchat.impl.directory.components.MemberCard +import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryErrorContent +import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryHeader +import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryPendingBanner +import io.element.android.features.healthchat.impl.healthChatUserMessage +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.SearchField +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun MemberDirectoryView( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + onOpenDm: (RoomId) -> Unit, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + state.eventSink(MemberDirectoryEvents.LoadIfNeeded) + } + Box(modifier = modifier.fillMaxSize()) { + when (val directory = state.directory) { + AsyncData.Uninitialized -> FullScreenLoading() + is AsyncData.Loading -> if (directory.prevData == null) { + FullScreenLoading() + } else { + MemberDirectoryContent( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + isRefreshing = true, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + ) + } + is AsyncData.Failure -> MemberDirectoryErrorContent( + message = directory.error.healthChatUserMessage(), + onRetry = { state.eventSink(MemberDirectoryEvents.Refresh) }, + ) + is AsyncData.Success -> MemberDirectoryContent( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + isRefreshing = false, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + ) + } + AsyncActionView( + async = state.startDmActionState, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_starting_chat), + ) + }, + onSuccess = onOpenDm, + errorMessage = { stringResource(HealthChatStrings.healthchat_screen_member_directory_error_starting_chat) }, + onRetry = null, + onErrorDismiss = { state.eventSink(MemberDirectoryEvents.ClearStartDmState) }, + confirmationDialog = { data -> + if (data is ConfirmingStartDmWithMatrixUser) { + CreateDmConfirmationBottomSheet( + matrixUser = data.matrixUser, + isUserIdentityUnknown = data.isUserIdentityUnknown, + onSendInvite = { + state.eventSink(MemberDirectoryEvents.StartDm(data.matrixUser)) + }, + onDismiss = { + state.eventSink(MemberDirectoryEvents.ClearStartDmState) + }, + ) + } + }, + ) + } +} + +@Composable +private fun MemberDirectoryContent( + state: MemberDirectoryState, + contentPadding: PaddingValues, + lazyListState: LazyListState, + isRefreshing: Boolean, + onMemberClick: ((UserId) -> Unit)?, + onMyProfileClick: (() -> Unit)?, + onEditProfileClick: (() -> Unit)?, + onInviteClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val directory = state.directory.dataOrNull() ?: return + Column(modifier = modifier.fillMaxSize()) { + if (isRefreshing) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize(), + ) { + item(key = "header") { + MemberDirectoryHeader( + memberCount = directory.totalCount, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = if (state.isApproved) onInviteClick else null, + onRefreshClick = { state.eventSink(MemberDirectoryEvents.Refresh) }, + ) + } + if (!state.isApproved) { + item(key = "pending") { + MemberDirectoryPendingBanner( + message = state.pendingMessage + ?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message), + ) + } + } else { + item(key = "search") { + SearchField( + state = state.searchQuery, + placeholder = stringResource(HealthChatStrings.healthchat_screen_member_directory_search_placeholder), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + if (directory.members.isEmpty()) { + item(key = "empty") { + val message = if (state.searchQuery.text.isNotEmpty()) { + stringResource(HealthChatStrings.healthchat_screen_member_directory_empty_search) + } else { + stringResource(HealthChatStrings.healthchat_screen_member_directory_empty) + } + Text( + text = message, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + ) + } + } + items(directory.members, key = { it.userId.value }) { member -> + MemberCard( + member = member, + onClick = onMemberClick?.let { callback -> { callback(member.userId) } }, + onStartChatClick = { state.eventSink(MemberDirectoryEvents.StartDm(member.toMatrixUser())) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + } +} + +@Composable +private fun FullScreenLoading( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@PreviewsDayNight +@Composable +internal fun MemberDirectoryViewPreview( + @PreviewParameter(MemberDirectoryStateProvider::class) state: MemberDirectoryState +) = ElementPreview { + MemberDirectoryView( + state = state, + contentPadding = PaddingValues(), + lazyListState = rememberLazyListState(), + onOpenDm = {}, + onMemberClick = {}, + onMyProfileClick = {}, + onEditProfileClick = null, + onInviteClick = null, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt new file mode 100644 index 0000000000..14bddb5298 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberCard.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.healthchat.api.HealthChatMember +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.api.bestDisplayName +import io.element.android.features.healthchat.api.directory.aDirectoryMember +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun MemberCard( + member: HealthChatMember, + onClick: (() -> Unit)?, + onStartChatClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .let { base -> onClick?.let { base.clickable(onClick = it) } ?: base }, + color = ElementTheme.colors.bgSubtleSecondary, + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val displayName = member.bestDisplayName( + fallback = stringResource(HealthChatStrings.healthchat_screen_member_directory_unknown_name), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar( + avatarData = AvatarData( + id = member.userId.value, + name = displayName, + url = member.avatarUrl, + size = AvatarSize.UserPreference, + ), + avatarType = AvatarType.User, + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = displayName, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + member.specialty?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textActionAccent, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + member.practiceName?.let { MemberCardDetailRow(icon = CompoundIcons.Company(), text = it) } + member.businessCity?.let { MemberCardDetailRow(icon = CompoundIcons.LocationPin(), text = it) } + member.businessEmail?.let { MemberCardDetailRow(icon = CompoundIcons.Email(), text = it) } + member.businessTel?.let { MemberCardDetailRow(icon = CompoundIcons.VoiceCall(), text = it) } + Text( + text = member.userId.value, + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton( + text = stringResource(HealthChatStrings.healthchat_screen_member_directory_start_chat), + onClick = onStartChatClick, + size = ButtonSize.Small, + ) + } + } +} + +@Composable +private fun MemberCardDetailRow( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier.size(16.dp), + ) + Text( + text = text, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MemberCardPreview() = ElementPreview { + MemberCard( + member = aDirectoryMember(), + onClick = {}, + onStartChatClick = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt new file mode 100644 index 0000000000..0e6e70392d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryErrorContent.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.healthchat.impl.directory.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun MemberDirectoryErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + BigIcon(style = BigIcon.Style.AlertSolid) + Text( + text = message, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Button( + text = stringResource(CommonStrings.action_retry), + onClick = onRetry, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MemberDirectoryErrorContentPreview() = ElementPreview { + MemberDirectoryErrorContent( + message = "Fehler beim Laden der Benutzer", + onRetry = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/components/MemberDirectoryHeader.kt new file mode 100644 index 0000000000..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 1a90245611..0886b186ec 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -31,5 +32,8 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + + // HealthChat fork: open a user profile from the member directory tab. + fun navigateToUserProfile(userId: UserId) } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index efdc545e61..44448fc6ad 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.features.announcement.api) + implementation(projects.features.healthchat.api) implementation(projects.features.invite.api) implementation(projects.features.networkmonitor.api) implementation(projects.features.logout.api) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 00f285e3f4..5336e08d70 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 @@ -87,6 +90,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( @@ -99,6 +104,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( @@ -244,7 +253,24 @@ class HomeFlowNode( onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom, modifier = Modifier ) - } + }, + memberDirectoryView = { contentPadding, lazyListState -> + memberDirectoryRenderer.Render( + state = state.memberDirectoryState, + contentPadding = contentPadding, + lazyListState = lazyListState, + onOpenDm = ::navigateToRoom, + onMemberClick = { callback.navigateToUserProfile(it) }, + onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) }, + // Hidden until the native editor and invite screens exist. + onEditProfileClick = null, + onInviteClick = null, + modifier = Modifier, + ) + }, + // Both levels: Home within the parent backstack (captured in View) and the + // root element within Home's own backstack. + isBackHandlingEnabled = isVisibleNavTarget.value && LocalNodeTargetVisibility.current, ) directLogoutView.Render(state.directLogoutState) } @@ -252,6 +278,13 @@ class HomeFlowNode( @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 1e49b1aa70..ece767e711 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.healthchat.api.directory.MemberDirectoryState import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.logout.api.direct.DirectLogoutState @@ -42,6 +43,8 @@ class HomePresenter( private val indicatorService: IndicatorService, private val roomListPresenter: Presenter, private val homeSpacesPresenter: Presenter, + // HealthChat fork: member directory tab presenter. + private val memberDirectoryPresenter: Presenter, private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, @@ -63,6 +66,7 @@ class HomePresenter( val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() + val memberDirectoryState = memberDirectoryPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } val currentHomeNavigationBarItem by remember { derivedStateOf { @@ -96,6 +100,7 @@ class HomePresenter( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + memberDirectoryState = memberDirectoryState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, directLogoutState = directLogoutState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index ae59ef8eb9..5852f6d969 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -8,6 +8,7 @@ package io.element.android.features.home.impl +import io.element.android.features.healthchat.api.directory.MemberDirectoryState import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spaces.HomeSpacesState @@ -27,6 +28,8 @@ data class HomeState( val currentHomeNavigationBarItem: HomeNavigationBarItem, val roomListState: RoomListState, val homeSpacesState: HomeSpacesState, + // HealthChat fork: state of the member directory tab. + val memberDirectoryState: MemberDirectoryState, val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, val directLogoutState: DirectLogoutState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt index ea9ff7a234..19c1f6e185 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt @@ -9,6 +9,8 @@ package io.element.android.features.home.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListStateProvider import io.element.android.features.home.impl.roomlist.aRoomListState @@ -50,6 +52,9 @@ open class HomeStateProvider : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + // HealthChat fork: appended LAST so upstream snapshot indices stay stable. + ) + aHomeState( + currentHomeNavigationBarItem = HomeNavigationBarItem.Members, ) } @@ -62,6 +67,7 @@ internal fun aHomeState( currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats, roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), + memberDirectoryState: MemberDirectoryState = aMemberDirectoryState(), canReportBug: Boolean = true, directLogoutState: DirectLogoutState = aDirectLogoutState(), eventSink: (HomeEvent) -> Unit = {} @@ -75,5 +81,6 @@ internal fun aHomeState( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + memberDirectoryState = memberDirectoryState, eventSink = eventSink, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6956ba6ba5..a78cbf6f58 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -86,6 +87,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 @@ -121,6 +127,8 @@ fun HomeView( onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, + memberDirectoryView = memberDirectoryView, + isBackHandlingEnabled = isBackHandlingEnabled, ) // This overlaid view will only be visible when state.displaySearchResults is true RoomListSearchView( @@ -147,7 +155,9 @@ private fun HomeScaffold( onStartChatClick: () -> Unit, onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, + isBackHandlingEnabled: Boolean, modifier: Modifier = Modifier, + memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit, ) { fun onRoomClick(room: RoomListRoomSummary) { onRoomClick(room.roomId) @@ -158,7 +168,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 { @@ -172,6 +183,7 @@ private fun HomeScaffold( val hazeState = rememberHazeState() val roomsLazyListState = rememberLazyListState() val spacesLazyListState = rememberLazyListState() + val membersLazyListState = rememberLazyListState() Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -208,6 +220,7 @@ private fun HomeScaffold( val lazyListStateTarget = when (item) { HomeNavigationBarItem.Chats -> roomsLazyListState HomeNavigationBarItem.Spaces -> spacesLazyListState + HomeNavigationBarItem.Members -> membersLazyListState } coroutineScope.launch { if (lazyListStateTarget.firstVisibleItemIndex > 10) { @@ -221,15 +234,15 @@ private fun HomeScaffold( state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item)) } }, - floatingActionButton = { - when (state.currentHomeNavigationBarItem) { - HomeNavigationBarItem.Chats -> { - HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) - } - HomeNavigationBarItem.Spaces -> { - HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) - } + floatingActionButton = when (state.currentHomeNavigationBarItem) { + HomeNavigationBarItem.Chats -> { + { HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) } } + HomeNavigationBarItem.Spaces -> { + { HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) } + } + // HealthChat fork: the member directory has no create action. + HomeNavigationBarItem.Members -> null }, ) }, @@ -285,6 +298,18 @@ private fun HomeScaffold( onExploreClick = {}, ) } + // HealthChat fork: member directory tab content. + HomeNavigationBarItem.Members -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .hazeSource(state = hazeState), + ) { + memberDirectoryView(contentPadding, membersLazyListState) + } + } } }, snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index a563520e09..d29d23e603 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -116,6 +116,8 @@ fun HomeTopBar( } } HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes) + // HealthChat fork: member directory tab title. + HomeNavigationBarItem.Members -> stringResource(selectedNavigationItem.labelRes) } Text( modifier = Modifier.semantics { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 8eee8e1ee2..d58bd7acb9 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.home.api.HomeEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.services.analytics.test.FakeAnalyticsService @@ -39,6 +40,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, + memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, ) } @@ -51,6 +53,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToEnterRecoveryKey() = lambdaError() override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() + override fun navigateToUserProfile(userId: UserId) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 371a718523..2f2ec442b0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.directory.aMemberDirectoryState import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState @@ -145,6 +146,21 @@ class HomePresenterTest { assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) } } + + @Test + fun `present - NavigationBar change to Members`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Members)) + val finalState = awaitItem() + assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Members) + } + } } internal fun createHomePresenter( @@ -162,6 +178,7 @@ internal fun createHomePresenter( indicatorService = indicatorService, roomListPresenter = { aRoomListState() }, homeSpacesPresenter = homeSpacesPresenter, + memberDirectoryPresenter = { aMemberDirectoryState() }, logoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, diff --git a/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() + } +} diff --git a/tests/uitests/src/test/kotlin/ui/LayoutLibErrorFilterStatement.kt b/tests/uitests/src/test/kotlin/ui/LayoutLibErrorFilterStatement.kt index 9f91f30816..5c8b711385 100644 --- a/tests/uitests/src/test/kotlin/ui/LayoutLibErrorFilterStatement.kt +++ b/tests/uitests/src/test/kotlin/ui/LayoutLibErrorFilterStatement.kt @@ -17,24 +17,130 @@ import org.junit.runners.model.Statement * Tracked in https://github.com/cashapp/paparazzi/issues/2342 — fixed in layoutlib 16.2.3. * Remove this workaround once Paparazzi ships with layoutlib >= 16.2.3. * - * Paparazzi's PaparazziLogger collects background thread errors and re-throws them via - * assertNoErrors(). This Statement wrapper catches the known NoSuchMethodError so the test - * can pass despite the LayoutLib bug. + * The bug has two faces, and which one a run gets is a thread race: + * + * 1. `HandlerThread_Delegate.run` calls `Looper.prepare()` and then dies on + * `Process.setThreadPriority`. If the caller reaches `HandlerThread.getLooper()` while the + * thread is still alive it gets the looper and nothing else happens. + * 2. If the thread has already died, `getLooper()` returns null, `BackgroundThread + * .ensureThreadReady()` NPEs, and the static initialiser of `LatencyTracker` fails. The JVM + * then marks that class erroneous forever, so every later preview in the same fork that shows + * the soft keyboard fails with `NoClassDefFoundError`. + * + * Paparazzi's PaparazziLogger collects these background errors and re-throws them from + * `teardown()` — as the error itself when there is one, or wrapped in a `MultipleFailuresException` + * when there are several. This rule swallows them, and only them. + * + * IMPORTANT: Paparazzi runs the test body as `try { body } finally { teardown() }`, so when + * teardown throws it *discards* whatever the body threw — including a real snapshot mismatch. + * [renderFailureCapture] must therefore be registered as an inner rule so the body's exception + * survives and wins. Without it, this rule could silently turn a genuine golden mismatch green. */ class LayoutLibErrorFilterStatement : TestRule { + private var captureRuleRan = false + private var thrownByTestBody: Throwable? = null + + /** + * Must be registered with `@get:Rule(order = 2)` so it is applied INSIDE the Paparazzi rule. + * JUnit 4.13.2 applies the *lowest* `order` outermost, so 0 = this filter, 1 = Paparazzi, + * 2 = the capture below. + */ + val renderFailureCapture: TestRule = TestRule { base, _ -> + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + captureRuleRan = true + try { + base.evaluate() + } catch (error: Throwable) { + thrownByTestBody = error + throw error + } + } + } + } + override fun apply(base: Statement, description: Description): Statement { return object : Statement() { @Throws(Throwable::class) override fun evaluate() { try { base.evaluate() - } catch (e: NoSuchMethodError) { - if (e.message?.contains("setPosixNicenessInternal") != true) throw e - } catch (npe: NullPointerException) { - // Also catch this error on `HorizontalFloatingToolbarPreview` and `HorizontalFloatingToolbarNoFabPreview` - if (npe.message?.contains("""Cannot invoke "java.util.ArrayList.size()" because "childrenList" is null""") != true) throw npe + } catch (error: Throwable) { + val fromBody = thrownByTestBody + // A failure raised by the test body itself always wins over a teardown error. + if (fromBody != null && !isKnownLayoutLibBug(fromBody)) throw fromBody + if (!captureRuleRan) { + throw IllegalStateException( + "renderFailureCapture is not registered with @get:Rule(order = 2) on ${description.className}, " + + "so a snapshot mismatch could be hidden by a LayoutLib teardown error. Refusing to filter.", + error, + ) + } + if (!isKnownLayoutLibBug(error)) throw error } } } } } + +private const val MULTIPLE_FAILURES_EXCEPTION = "app.cash.paparazzi.internal.PaparazziLogger\$MultipleFailuresException" + +private val knownLayoutLibBugMarkers = listOf( + // The root bug: LayoutLib calls a JVM-internal method that does not exist. + "setPosixNicenessInternal", + // Its fallout: the poisoned static initialiser and the dead "android.bg" HandlerThread. + "com.android.internal.util.LatencyTracker", + "com.android.internal.os.BackgroundThread", + """Cannot invoke "android.os.Looper.setTraceTag(long)" because "looper" is null""", + // Seen on HorizontalFloatingToolbarPreview and HorizontalFloatingToolbarNoFabPreview. + """Cannot invoke "java.util.ArrayList.size()" because "childrenList" is null""", +) + +/** + * True only when *every* error carried by [error] is a known LayoutLib bug. Anything we cannot + * decompose, and anything with an unrecognised error in it, is reported as a failure. + */ +private fun isKnownLayoutLibBug(error: Throwable): Boolean { + val reports = describeEach(error) ?: return false + return reports.isNotEmpty() && reports.all { report -> knownLayoutLibBugMarkers.any { report.contains(it) } } +} + +/** + * One text report per collected error, or null when a `MultipleFailuresException` cannot be + * unpacked. It is `internal`, and it neither exposes its causes nor prints them via + * `stackTraceToString()`, so reflection is the only way to inspect them individually. + */ +private fun describeEach(error: Throwable): List? { + if (error.javaClass.name != MULTIPLE_FAILURES_EXCEPTION) { + return listOf(error.describe()) + } + val causes = readCauses(error) ?: return null + val reports = mutableListOf() + for (cause in causes) { + val nested = describeEach(cause) ?: return null + reports += nested + } + return reports +} + +@Suppress("UNCHECKED_CAST") +private fun readCauses(error: Throwable): List? { + return try { + val field = error.javaClass.getDeclaredField("causes") + field.isAccessible = true + field.get(error) as? List + } catch (failure: Exception) { + null + } +} + +private fun Throwable.describe(): String { + // `takeWhile { seen.add(it) }` also terminates a self-referential cause chain. + val seen = mutableSetOf() + return generateSequence(this) { it.cause } + .takeWhile { seen.add(it) } + .joinToString(separator = "\n") { error -> + error.stackTrace.joinToString(separator = "\n", prefix = "$error\n") { frame -> "\tat $frame" } + } +} diff --git a/tests/uitests/src/test/kotlin/ui/PreviewA11yTest.kt b/tests/uitests/src/test/kotlin/ui/PreviewA11yTest.kt index f420110f31..9afae1a0dc 100644 --- a/tests/uitests/src/test/kotlin/ui/PreviewA11yTest.kt +++ b/tests/uitests/src/test/kotlin/ui/PreviewA11yTest.kt @@ -39,6 +39,10 @@ class PreviewA11yTest( renderExtensions = setOf(AccessibilityRenderExtension()), ) + // Applied inside the Paparazzi rule so a snapshot mismatch is not discarded by teardown(). + @get:Rule(order = 2) + val renderFailureCapture = layoutLibErrorFilterStatement.renderFailureCapture + @Test fun snapshot() { ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en") diff --git a/tests/uitests/src/test/kotlin/ui/PreviewShard1Test.kt b/tests/uitests/src/test/kotlin/ui/PreviewShard1Test.kt index 17632c212d..35257fa329 100644 --- a/tests/uitests/src/test/kotlin/ui/PreviewShard1Test.kt +++ b/tests/uitests/src/test/kotlin/ui/PreviewShard1Test.kt @@ -34,6 +34,10 @@ class PreviewShard1Test( @get:Rule(order = 1) val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en") + // Applied inside the Paparazzi rule so a snapshot mismatch is not discarded by teardown(). + @get:Rule(order = 2) + val renderFailureCapture = layoutLibErrorFilterStatement.renderFailureCapture + @Test fun snapshot() { ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en") diff --git a/tests/uitests/src/test/kotlin/ui/PreviewShard2Test.kt b/tests/uitests/src/test/kotlin/ui/PreviewShard2Test.kt index cec2b028ac..c6dfc498d9 100644 --- a/tests/uitests/src/test/kotlin/ui/PreviewShard2Test.kt +++ b/tests/uitests/src/test/kotlin/ui/PreviewShard2Test.kt @@ -34,6 +34,10 @@ class PreviewShard2Test( @get:Rule(order = 1) val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en") + // Applied inside the Paparazzi rule so a snapshot mismatch is not discarded by teardown(). + @get:Rule(order = 2) + val renderFailureCapture = layoutLibErrorFilterStatement.renderFailureCapture + @Test fun snapshot() { ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en") diff --git a/tests/uitests/src/test/kotlin/ui/PreviewShard3Test.kt b/tests/uitests/src/test/kotlin/ui/PreviewShard3Test.kt index c752d0b321..c53364c64e 100644 --- a/tests/uitests/src/test/kotlin/ui/PreviewShard3Test.kt +++ b/tests/uitests/src/test/kotlin/ui/PreviewShard3Test.kt @@ -34,6 +34,10 @@ class PreviewShard3Test( @get:Rule(order = 1) val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en") + // Applied inside the Paparazzi rule so a snapshot mismatch is not discarded by teardown(). + @get:Rule(order = 2) + val renderFailureCapture = layoutLibErrorFilterStatement.renderFailureCapture + @Test fun snapshot() { ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en") diff --git a/tests/uitests/src/test/kotlin/ui/PreviewShard4Test.kt b/tests/uitests/src/test/kotlin/ui/PreviewShard4Test.kt index 55881de079..fe5b6d041e 100644 --- a/tests/uitests/src/test/kotlin/ui/PreviewShard4Test.kt +++ b/tests/uitests/src/test/kotlin/ui/PreviewShard4Test.kt @@ -34,6 +34,10 @@ class PreviewShard4Test( @get:Rule(order = 1) val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en") + // Applied inside the Paparazzi rule so a snapshot mismatch is not discarded by teardown(). + @get:Rule(order = 2) + val renderFailureCapture = layoutLibErrorFilterStatement.renderFailureCapture + @Test fun snapshot() { ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en") diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Day_0_en.png new file mode 100644 index 0000000000..32f58ea975 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f6389635e4bf31d05b63fffea64bf4e031a0772f08d63ea99a2f093afec7577 +size 28208 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Night_0_en.png new file mode 100644 index 0000000000..50ce2922b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberCard_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33b68ade07f7c6c17d0d781f30523412b42a6804114b101af91096c1c746473a +size 27928 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Day_0_en.png new file mode 100644 index 0000000000..681b313aa9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425603e1c99ed96fd58c6dc4bf17436135f42fc130af9cba031fdd7abcebdaf3 +size 12001 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Night_0_en.png new file mode 100644 index 0000000000..9570abc393 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryErrorContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb075c9bf32a98df531cc5ab5baac6d5b2073e264e701ac3cab1fc4329d36b8 +size 11975 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Day_0_en.png new file mode 100644 index 0000000000..e30b6aedcc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5f89026226cc16350afa1ddaf2c79370912804645b0880639204ab0fb7cabed +size 8751 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Night_0_en.png new file mode 100644 index 0000000000..486cf93c59 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryHeader_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aae4d33c3a26f3f974795f21583463af868240d6e5831096b15f0440dc5a8cb +size 8384 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Day_0_en.png new file mode 100644 index 0000000000..a9188968c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54bc768b15b03d7aa359237f94a0c581f793743299eac42e69984edf47d4c407 +size 22908 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Night_0_en.png new file mode 100644 index 0000000000..111939486a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory.components_MemberDirectoryPendingBanner_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c55cd38c77016f63d1d58abb601c383eabf06bb33589c0b78524489f1ad81e57 +size 22033 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_0_en.png new file mode 100644 index 0000000000..d187a00939 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4372d95814bf2893940b9568b33d317e57efe8d2e1b1b4d31f13ba63a8b7a24 +size 62499 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_1_en.png new file mode 100644 index 0000000000..e3e5480add --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e73b036458ee32e207f711cf6656fe7646b23d3d9e096e62932c828dd53189 +size 5244 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_2_en.png new file mode 100644 index 0000000000..91ef97d5f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8601efbcbb55c49be61521dbea2878975be82e0c967a1ab763b9d79a307513c8 +size 62731 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_3_en.png new file mode 100644 index 0000000000..681b313aa9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425603e1c99ed96fd58c6dc4bf17436135f42fc130af9cba031fdd7abcebdaf3 +size 12001 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_4_en.png new file mode 100644 index 0000000000..bb32ab1a28 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bc85d1d728dc5406dbed71d0694eb6e3f155dd6611c9ef5f13c09bf4fb9c8d7 +size 30349 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_5_en.png new file mode 100644 index 0000000000..2d5e9a39fb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68890517e79280bcf737b5b142e5149d3df5d905c416026cb221e50f269314ce +size 22427 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_0_en.png new file mode 100644 index 0000000000..c3884f1f9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f647a882a0f17d7bb2015fced96fc7b0dc62e12fea7db5e6e1624a331ba2edb3 +size 61112 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_1_en.png new file mode 100644 index 0000000000..6582264383 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518818c549548b6304d2960242ce7251bb609fa439928539a7556c33223ca8ba +size 5251 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_2_en.png new file mode 100644 index 0000000000..09ce8a0e8b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95b55f3d720f68f22cf3f7a0bb0243343f251a99bee544c8372d7bee9dabcc6a +size 61267 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_3_en.png new file mode 100644 index 0000000000..9570abc393 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb075c9bf32a98df531cc5ab5baac6d5b2073e264e701ac3cab1fc4329d36b8 +size 11975 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_4_en.png new file mode 100644 index 0000000000..ff17e81aa5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7dc9b0cd64526244a8c79acbc943a79a44b92a3fc5852509151b83c7d377281 +size 29198 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_5_en.png new file mode 100644 index 0000000000..6228e0bdfd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.directory_MemberDirectoryView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:caeaf6ef97c06b9a104a998aadf801a3dc503e6cb151e5a326f48dae36d54204 +size 21420 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png index 344bd350d6..41fb756be6 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5dde8ed5c776f4701f24dd7d411dd78f48adab261efad71e961c845e24d12f3 -size 144367 +oid sha256:4279fe38dc2cbf4accefd27c494d30026a653285ddd41d998c57a0988fd8f34c +size 145743 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png index f106244405..5f3dc374ba 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c393b13532fba4d4ef25dda45b6ca3e1f6da6d49c5b2de412beded261a4f9311 -size 36164 +oid sha256:8aa0b89a8027f48e475297605640861805782129d4aa6434b4db0115a3e11997 +size 37341 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png index f2a69ea475..86a5f48ea4 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b493bc5151f15d6ba91bfb0c27b41f3abcf13ee7d511aa59db171cecdbd95f3 -size 31958 +oid sha256:e2948c30cb3ac117b376b4e2ac0358c21a4961a69181f245e302891f4b3c3a93 +size 33767 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png index f4dff45422..c2d9207799 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a33681fe064d5e12f53273bfd9262af8c7b902e237cb8d0641573b0645e291c -size 94687 +oid sha256:2e8250d679bf891c8ba0aac9b75b896e9cb51573dc5744f6c06918328e5bd06d +size 96132 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png index bdad242551..d4a58bd22c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:022954bdca89572b74bc70f6d91820e99eef8826409c0a06e6145a5c3ec8eaf4 -size 87268 +oid sha256:f55e99dc15e3d6e6de35a4963ec5daebb4ae7951381c2ac068ae558f7321244d +size 89124 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png index edf07a66f3..abddbab3ef 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af36d0e3383935d256b3730e6f955e858ae42a82f54485b879e7a6a71fc25f6c -size 55237 +oid sha256:07b0a251e29984fcbad6d3a60d4ff803e2c2fe9f2572112af9b39686f011bb93 +size 56352 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png index aa313049f5..4e7726b79f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e55407ac7116b3127a979f630777d0d528a349c38d37829d8bc0c37a316c7c5b -size 42526 +oid sha256:f66ad2e82f0d6a532ceb21f6e870993b88e3724877a56c4cd51fb1f9ad1b1810 +size 43683 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png new file mode 100644 index 0000000000..8715d77372 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a195d78b1a37e196d21d9dae74d0b3a86c9f553333ed7fd67041e89a7b1e92a1 +size 21184 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png index e01335907a..01d36a5ca2 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25e5ac35ddf79fdc78c21dbd031f506da04caac7bdd53f279c88fd4574f72a82 -size 64644 +oid sha256:67fffcd83343ed27d94be8f00d0ae6d831c254210825c5368692bc71f84dbab7 +size 65929 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png index fe41a54fcc..4f9adb7665 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c1245da0a4cf56aba8e102e7f297996ed58fc8c50165288dea11ca86fbc0dcc -size 57235 +oid sha256:d16c165b825131579a42cd11642d66403d66901b6b2f717054e482b3d560ce3b +size 58272 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png index b5f80f2faf..2dcd259c30 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:ff032e275d9b8468999bae5800ddebf5779c6bd3c037ef9e899fd191e7696103 +size 68788 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png index b6779aca93..6798cdbc1c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0769e243dc60de52c16df23a86eb62167bb60cb756d113f88b9b476c025e402b -size 87117 +oid sha256:2a637d4c8b06fac2126e9d9ad8d6cc2bafa320736bb63d84f00ec747fd9ffdaf +size 88975 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png index 420cb7c631..259a52a65c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d56c1af9fc73f4aaeed33dd8b6113ec64ddbfdf63506aa31ae6996732870baa2 -size 31127 +oid sha256:3b56caa6b22f3b196ffa0aa3f1078e50e8279a34ce80a7a80f3520fab3bd1686 +size 32168 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png index f36b5c2f83..da3b5737bb 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c3fb8ae530989e5a6d6e4193fbac4d4356af34076b684b782a0f8fec31226f1 -size 26657 +oid sha256:34f0ee3e98c2a9dd2f4a2d7f6f0ea882065a76aa1f13244ad089b5f845dff6d1 +size 27623 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png index 48b07fc9bd..a449b2df1c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d302be6dbaef08b501f4f33d3e01338e068767702a7018bd5bb24ade6eee2cb -size 88558 +oid sha256:3b62578b9e60a843095f86bd044f8d002abb6ae2d1060f31e91d0ac9a4352805 +size 89341 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png index 5cb7d766cd..8dd0458515 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6be5cd35eec917bf1c96d03079df8235d0bc7b52159f54c8a177a64f648e58c2 -size 81212 +oid sha256:148cdf26fd055c0247fc8e64a927e01c5881145f376e98223a3f558282629a7a +size 82151 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png index 2691d1c1df..cbc0f7a7b1 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d241a4270bef0a1f80c62c302cb5b40b4370f73e8175a746d32c90d1f98208 -size 49882 +oid sha256:9cb7fb35d7b6d091679ee928cec3a7b521d6c34425b9ad7e6b49e372e9ac99b3 +size 50856 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png index 451ca0c3ec..5beb60984f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1838a9839badf62d4d427a66a15a7d220996bdb0b60f0373249d62e4afae2910 -size 37390 +oid sha256:b07834c3eb7226d88ab19576d29e1b0b7efeb6b2c04fbaa45a7411e507108d81 +size 38388 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png new file mode 100644 index 0000000000..f44be3935f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6cce1b807bcaea11d702c24fd5341dd1fe615461bc3bfe68b8dec3a8b0f3e03 +size 17275 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png index 89c4934dd9..31c5c1838a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8172ca867a8c2e827ead36a22cefe967a5b57c69cae6d5aca1c941e4dd2ba16f -size 60160 +oid sha256:c651e0f5c8a2f2611b6e31dcf519f49d310e4cc476cc2160a272d0b0670dd76b +size 60826 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png index 390c9f6818..3306a382c3 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13740b4fc4cbd24075606e45bf070a8fbb762d298857bc071399def91dda7175 -size 52189 +oid sha256:face1048515f0f05f06ddcdd922397d15458c5eb2a1773fd853986b21380f497 +size 53053 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png index fe171e26b7..1b0620e9a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:f41e4f7932ebaa69de11a22136d483b0de273aef58c9ea885b4f5ceeaaf62e65 +size 63415 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png index 1092dcd1b9..2ca6fbb8d8 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bce0bc8590037705369735e83e576f0818f4528298b4b2face999c8802f855b9 -size 81081 +oid sha256:17838682d1f7ced43bca749e32a9fbcf7ec9d286f72d868013b81ca422e30c07 +size 82039 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png index f0bfd0f2b4..33110584a9 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e82c6e4ff563e7ae870431528d9413ac915e16ca0151aa3c86ff5081bfc72478 -size 27563 +oid sha256:dbdf9740221b82aaaf332c95e7299594f0b4fdd258b2e9c733133bdd4a01f692 +size 28378 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png index 438d96608d..42ecbe8a68 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae0b92a52a7f2c0533af7d7f0fbddc6497778e7c5dcedc3344a2ba60a6a948f8 -size 40439 +oid sha256:471abc7c54d0f90ad5a923bd21abbf0960bbb9cbbdb22313a779083baf61ab3f +size 40671 diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png index 5bc0a7e57a..d9b4151b77 100644 --- a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da71297e7073c5c592dc154af7f137c4eb500bf528e1509b95da869a2c61cbd2 -size 30488 +oid sha256:6f91ec4d46970dd80c97efc76361ce26b3e705b037b99bc032205ace3d2c8749 +size 31953 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png index a7ce4c15ec..bf25a07679 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffd4f5b8fece04b00ad5d866f6fc11e753e800f956c966e703d1dfc66f1ce8e7 -size 37257 +oid sha256:a52d39cb27572c49ab64b1534ead353687d83870b5d5cfe7158859925b4717f3 +size 34926 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png index db11b33396..0c9a67252c 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1ffc8d536a239ca11de23f3f8e5fb14b429b9beee03c80bbca0321ec853f5e0 -size 26638 +oid sha256:9f3fb75974ce37fc3ce5e303ab5573a0d9a769f3c079de5b0f6462b40e53c1cd +size 26713 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png index aa539ed8a1..dbd4836467 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc1e9df75619bde2571bad0362f147c627ee44a780fbaa9a2c0e5a49e252d14b -size 19881 +oid sha256:d3260d344a065f1710aff1450dbdaaa0bd1fbd963d5304167124d845c46be433 +size 19903 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png index aa539ed8a1..dbd4836467 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc1e9df75619bde2571bad0362f147c627ee44a780fbaa9a2c0e5a49e252d14b -size 19881 +oid sha256:d3260d344a065f1710aff1450dbdaaa0bd1fbd963d5304167124d845c46be433 +size 19903 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png index 1f8c2d110d..541b2a97e1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f139526f69881e08b17d15013d78fb3eeca76eea5b18d378953c37fbd9d76921 -size 44064 +oid sha256:038e12f3caeef6ac8d5389b7cdc68138e089dcac335d0a5904adc55c9bcb7b1c +size 40642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png index 3192cf2a5e..8edee63bc1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1885bbe8b0ea497ac20d7bcdd0c6a93ba0533e898378d35e1cafdc8c9924fd56 -size 279984 +oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884 +size 255933 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png index 693a36b7dd..5f51b27f5e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3dbb8ef39b6a7a0c2f576d8bd101aa97c89da696f23e6d5836846494471c01a1 -size 55799 +oid sha256:3274314ab9e39c23a6b9d66adf80ac8bb7b364ce60a300f96582374a18bc821f +size 54590 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png index 0f6de1b844..bd3b0196d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28512b0fda0b71b98eab03cee15c9d3163825b1a3545670cfa277ddd5434c87a -size 58417 +oid sha256:6f7f7415abbe51858f938e16ad03c6aab983fb3d5e2a53a028bae3c031446646 +size 67848 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png index bd73e322ab..93622cedfe 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 -size 3657 +oid sha256:87193a7c9eb9d40234970e5f5be02e80f2b3ffe91e2c9ca6afd58b8bccdd5911 +size 55075 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png index 3f12875a5b..06dd93b269 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031 -size 294992 +oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c +size 295031 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png index ab6392a424..0435f55de6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94f89a37549a3f198442db467bc7d8539f0b5649d97927bfe731f090d6dfb03d -size 379505 +oid sha256:ff839731834d7d0c94fa9a2c412014ddbaeaa053c5af1c8cbd00a3f3a583f364 +size 379511 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png index 1f0fdabe6d..3ac59caeea 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8bb5a6de6601da8d7fe59f110a054577e2e25dae4bb449f60c04e7d7a1b9551 -size 365073 +oid sha256:1a94d5198e27338675c0e15df5874ba3272ee315b8d77291dc7ccc7c754ed1f4 +size 365082 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png index 84d9d4e5c3..94428abb56 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c609a633ab23eae5729ecb57a17a385bac65e81cbf0180c01e0847f1028039e -size 347890 +oid sha256:36d7cf2510d546d2b330b1d1b1674ce58df0558c547e443794a1e7b2222add87 +size 347897 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png index 01d4dca84b..67490ba700 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef2bf449671b652cb152b3baab92ab100eab77a54229ab49daeab156f2f484d0 -size 369724 +oid sha256:f4431e2d9f30123949ea5676ce60f642234adcf9b5a33f2acc3f80ebf6609a66 +size 369730 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png index a332744358..14a40a5676 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:588967869cbd25ed8b82aff5618175660a8a9e45a5a861b1c7867cf1913f575b -size 364646 +oid sha256:a11044fe90227f1f316a1ade8587e545bb620e11cca76865fc2eb83cdb817fc5 +size 364655 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png index 6e83d6491d..d235670def 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34152c742abc486a48234e22101833bc163cf11979890d06aac41e68e3c404d0 -size 364846 +oid sha256:a00bd766a00b7e7c6615fc53bf469b3ca5056a6c514c096431ae38d99d604306 +size 364845 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png index 77abe20982..422c324695 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c3d87862c6d7fc88d0f23cd54c5b9f77028992fdd7c282a195b964dd17e85ac -size 367855 +oid sha256:a1df8d22766981fc76d6d767dc3f40d98cadc7c51be20417b067b91af604232b +size 367884 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png index a431a9429a..e9d85990e8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93c2df04d65f6078ce3f7067fbae8a7317ec42ed0fc013dee962a3953269f3b7 -size 362813 +oid sha256:66d4b6ea4a697735bbb0fac936b89cde99c5077973ba828894bf50b5dae233b3 +size 362848 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png index 1a8ea3e4e9..0cfd1dc985 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66aba7b4271a990fac85b0bbc7cbdc371fa1e38a4da5bb4e2f62a80b21cb3a8f -size 363012 +oid sha256:545c9e8933e420cc3f8fbba0fbd5162c946c65a20721faeb77c568596ca720cd +size 363045 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_0_en.png index 5f724184b0..9c5932834e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:179b30927c3db3963bef12243ae69908ad0b99275906197abd02c110f012213f -size 57267 +oid sha256:0dd30568e628750f922a87c2df1fd7751e9d899b9163253c9f2a0c7b03e4a869 +size 56247 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_4_en.png index c891dbf206..3cd7dd274c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:507ab916afdc3f4d1429ef70c6e19dac8a80713636b064657430c19252494098 -size 55558 +oid sha256:b2a89e79995050e8b24b459172a7c123cc08c64097d5d735cef8c47f913a3bf4 +size 54080 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png index f654a4403c..e4a14ae551 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e11d2920b56154389cffd1d56b986de2a570bff474d4f4861c710239d27c44e0 -size 58442 +oid sha256:39430896a687266d7a60890fdac5dbca6d36c87fe30d07619c9c50e23c3f77be +size 56972 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_0_en.png index 1f5ddc83fd..b3c490ad32 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f3b1665b2038873c849362b16a04713ef89d17a9b3e44dc828754110e192e33 -size 56407 +oid sha256:30e13e1b91d681088ceba71f21f668a5d8b8f376cb10832f0289b1b14c1103e4 +size 55569 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_4_en.png index b2e5208390..6b4cb7360c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72460dfd88da9b6ebf586e98a819dcc6d377da3515df35a0a2b7247fa376f7d8 -size 54987 +oid sha256:abf3863e6d5ddc747dfea06a006983345937cd85d0e89b60bc625f4ad16b2691 +size 52906 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png index 46c4466773..1ad21c4d64 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7ba8b81a3df9e797ca317a450a7f1bf0a8391aa6b919136827b5ab8e324cdb7 -size 57057 +oid sha256:289819914863942caad5202131efddd9b880b4af64a36d4b45fb547b5b1ec47f +size 55916 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png index 7cca911c45..074820e157 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26e14b77fa3c3aeaea58782309e6de8b9047e07945009322990ebf5f4c283309 -size 52770 +oid sha256:7caacb336793b8ae4b83e9a10a82325e25e8b3c036f881898ed28688250eb6e0 +size 55744 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png index 616c06bf46..36d19c6b72 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ce2519906f1aaf34700ca9ba4bdeaa72030f1f5aff3f7e24352a1435ac67d98 -size 53966 +oid sha256:7dfa2767c55a81d3f121b8fa55443b7b05f9369e91639b80e0be349863799ad7 +size 55438 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png index 58546b8522..ee5c582240 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30357bf18792db5ab20085010251170a724ffe8c00313facd04ee704e806af64 -size 10982 +oid sha256:75de341f705bb22955f9399180519b65ad59c2e1917317d56e50ea4db78f35bc +size 12102