mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
feat(healthchat): add member directory tab to home navigation
Third bottom-nav tab backed by the HealthChat API: searchable member cards with native start-chat, approval-pending banner, retry on failure and navigation into the native profile screens.
This commit is contained in:
@@ -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,
|
||||
|
||||
+12
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+26
@@ -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
|
||||
}
|
||||
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
+30
@@ -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<HealthChatMemberDirectory>,
|
||||
val startDmActionState: AsyncAction<RoomId>,
|
||||
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
|
||||
}
|
||||
+99
@@ -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<HealthChatMemberDirectory> = AsyncData.Success(aMemberDirectory()),
|
||||
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
eventSink: (MemberDirectoryEvents) -> Unit = {},
|
||||
) = MemberDirectoryState(
|
||||
searchQuery = searchQuery,
|
||||
directory = directory,
|
||||
startDmActionState = startDmActionState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aMemberDirectory(
|
||||
members: List<HealthChatMember> = 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<HealthChatMember> = 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,
|
||||
)
|
||||
@@ -20,4 +20,21 @@
|
||||
<string name="healthchat_field_further_info">Weitere Infos / Qualifikationen</string>
|
||||
<string name="healthchat_profile_error_loading">Die HealthChat-Profildaten konnten nicht geladen werden.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Mitglieder</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d Mitglied</item>
|
||||
<item quantity="other">%1$d Mitglieder</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">Mein Profil</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Suche nach Name, Praxis, Ort oder Fachgebiet</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Chat starten</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Fehler beim Erstellen des Chats</string>
|
||||
<string name="healthchat_screen_member_directory_empty">Es sind noch keine Mitglieder vorhanden.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">Keine Mitglieder entsprechen Ihrer Suche.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Unbekannt</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Konto wird geprüft</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.</string>
|
||||
<string name="healthchat_a11y_refresh">Aktualisieren</string>
|
||||
<string name="healthchat_a11y_edit_profile">Profil bearbeiten</string>
|
||||
<string name="healthchat_a11y_invite_member">Neues Mitglied einladen</string>
|
||||
</resources>
|
||||
|
||||
@@ -20,4 +20,22 @@
|
||||
<string name="healthchat_field_further_info">Informations complémentaires / qualifications</string>
|
||||
<string name="healthchat_profile_error_loading">Les détails du profil HealthChat n’ont pas pu être chargés.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">Les détails du profil seront visibles dès que votre compte aura été approuvé.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Membres</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d membre</item>
|
||||
<item quantity="many">%1$d membres</item>
|
||||
<item quantity="other">%1$d membres</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">Mon profil</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Rechercher par nom, cabinet, ville ou spécialité</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Démarrer une discussion</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Impossible de démarrer la discussion</string>
|
||||
<string name="healthchat_screen_member_directory_empty">Il n’y a pas encore de membres.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">Aucun membre ne correspond à votre recherche.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Inconnu</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Compte en cours de vérification</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres.</string>
|
||||
<string name="healthchat_a11y_refresh">Actualiser</string>
|
||||
<string name="healthchat_a11y_edit_profile">Modifier le profil</string>
|
||||
<string name="healthchat_a11y_invite_member">Inviter un nouveau membre</string>
|
||||
</resources>
|
||||
|
||||
@@ -20,4 +20,22 @@
|
||||
<string name="healthchat_field_further_info">Ulteriori informazioni / qualifiche</string>
|
||||
<string name="healthchat_profile_error_loading">Impossibile caricare i dettagli del profilo HealthChat.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Membri</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d membro</item>
|
||||
<item quantity="many">%1$d membri</item>
|
||||
<item quantity="other">%1$d membri</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">Il mio profilo</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Cerca per nome, studio, città o specializzazione</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Avvia chat</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Impossibile avviare la chat</string>
|
||||
<string name="healthchat_screen_member_directory_empty">Non ci sono ancora membri.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">Nessun membro corrisponde alla tua ricerca.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Sconosciuto</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Account in fase di verifica</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri.</string>
|
||||
<string name="healthchat_a11y_refresh">Aggiorna</string>
|
||||
<string name="healthchat_a11y_edit_profile">Modifica profilo</string>
|
||||
<string name="healthchat_a11y_invite_member">Invita un nuovo membro</string>
|
||||
</resources>
|
||||
|
||||
@@ -21,4 +21,21 @@
|
||||
<string name="healthchat_field_further_info">Further info / qualifications</string>
|
||||
<string name="healthchat_profile_error_loading">The HealthChat profile details couldn’t be loaded.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">Profile details become visible once your account has been approved.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Members</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">My profile</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Search by name, practice, city or specialty</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Start chat</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Failed to start the chat</string>
|
||||
<string name="healthchat_screen_member_directory_empty">There are no members yet.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">No members match your search.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Unknown</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Account under review</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Your account is currently under review. Once it has been approved you can see and search all members.</string>
|
||||
<string name="healthchat_a11y_refresh">Refresh</string>
|
||||
<string name="healthchat_a11y_edit_profile">Edit profile</string>
|
||||
<string name="healthchat_a11y_invite_member">Invite a new member</string>
|
||||
</resources>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+30
@@ -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)
|
||||
}
|
||||
+48
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+93
@@ -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<MemberDirectoryState> {
|
||||
@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<HealthChatMemberDirectory>>(AsyncData.Uninitialized) }
|
||||
val startDmActionState = remember { mutableStateOf<AsyncAction<RoomId>>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+42
@@ -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<MemberDirectoryState> {
|
||||
override val values: Sequence<MemberDirectoryState>
|
||||
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())),
|
||||
),
|
||||
)
|
||||
}
|
||||
+234
@@ -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,
|
||||
)
|
||||
}
|
||||
+146
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+63
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.healthchat.api.HealthChatStrings
|
||||
import io.element.android.features.healthchat.api.R as HealthChatR
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
|
||||
@Composable
|
||||
fun MemberDirectoryHeader(
|
||||
memberCount: Int,
|
||||
onMyProfileClick: (() -> Unit)?,
|
||||
onEditProfileClick: (() -> Unit)?,
|
||||
onInviteClick: (() -> Unit)?,
|
||||
onRefreshClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
HealthChatR.plurals.healthchat_screen_member_directory_member_count,
|
||||
memberCount,
|
||||
memberCount,
|
||||
),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
onMyProfileClick?.let {
|
||||
TextButton(
|
||||
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_my_profile),
|
||||
onClick = it,
|
||||
)
|
||||
}
|
||||
onEditProfileClick?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_edit_profile),
|
||||
)
|
||||
}
|
||||
}
|
||||
onInviteClick?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.UserAdd(),
|
||||
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_invite_member),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRefreshClick) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Restart(),
|
||||
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_refresh),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MemberDirectoryHeaderPreview() = ElementPreview {
|
||||
MemberDirectoryHeader(
|
||||
memberCount = 42,
|
||||
onMyProfileClick = {},
|
||||
onEditProfileClick = null,
|
||||
onInviteClick = null,
|
||||
onRefreshClick = {},
|
||||
)
|
||||
}
|
||||
+44
@@ -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.",
|
||||
)
|
||||
}
|
||||
+24
@@ -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<MemberDirectoryState>
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory
|
||||
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatDirectoryService
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
|
||||
import io.element.android.features.healthchat.test.FakeHealthChatDirectoryService
|
||||
import io.element.android.features.healthchat.test.aHealthChatMember
|
||||
import io.element.android.features.invitepeople.test.FakeStartDMAction
|
||||
import io.element.android.features.startchat.api.StartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
class MemberDirectoryPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - nothing is fetched before LoadIfNeeded`() = runTest {
|
||||
val searchLambda = lambdaRecorder<String, Result<HealthChatMemberDirectory>> {
|
||||
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<String>()
|
||||
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<AsyncAction<RoomId>> ->
|
||||
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<HealthChatMember> = listOf(aHealthChatMember()),
|
||||
searchQuery: String = "",
|
||||
isApproved: Boolean = true,
|
||||
pendingMessage: String? = null,
|
||||
) = HealthChatMemberDirectory(
|
||||
members = persistentListOf(*members.toTypedArray()),
|
||||
totalCount = members.size,
|
||||
searchQuery = searchQuery,
|
||||
isApproved = isApproved,
|
||||
pendingMessage = pendingMessage,
|
||||
)
|
||||
|
||||
private fun createPresenter(
|
||||
directoryService: HealthChatDirectoryService = FakeHealthChatDirectoryService(),
|
||||
startDMAction: StartDMAction = FakeStartDMAction(),
|
||||
): MemberDirectoryPresenter {
|
||||
return MemberDirectoryPresenter(
|
||||
directoryService = directoryService,
|
||||
startDMAction = startDMAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
+159
@@ -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<ComponentActivity> {
|
||||
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>()
|
||||
setMemberDirectoryView(
|
||||
state = aMemberDirectoryState(
|
||||
directory = AsyncData.Uninitialized,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(MemberDirectoryEvents.LoadIfNeeded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking retry on the error state emits Refresh`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>()
|
||||
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<ComponentActivity> {
|
||||
val member = aDirectoryMember()
|
||||
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>()
|
||||
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<ComponentActivity> {
|
||||
val member = aDirectoryMember()
|
||||
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>(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<ComponentActivity> {
|
||||
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>(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<ComponentActivity> {
|
||||
val eventsRecorder = EventsRecorder<MemberDirectoryEvents>(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<ComponentActivity>.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+18
-1
@@ -32,6 +32,7 @@ import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
|
||||
import io.element.android.features.home.api.HomeEntryPoint
|
||||
import io.element.android.features.home.impl.components.RoomListMenuAction
|
||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||
@@ -87,6 +88,8 @@ class HomeFlowNode(
|
||||
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
|
||||
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
|
||||
private val leaveRoomRenderer: LeaveRoomRenderer,
|
||||
// HealthChat fork: renders the member directory tab.
|
||||
private val memberDirectoryRenderer: MemberDirectoryRenderer,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
) : BaseFlowNode<HomeFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
@@ -244,7 +247,21 @@ class HomeFlowNode(
|
||||
onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
},
|
||||
memberDirectoryView = { contentPadding, lazyListState ->
|
||||
memberDirectoryRenderer.Render(
|
||||
state = state.memberDirectoryState,
|
||||
contentPadding = contentPadding,
|
||||
lazyListState = lazyListState,
|
||||
onOpenDm = ::navigateToRoom,
|
||||
onMemberClick = { callback.navigateToUserProfile(it) },
|
||||
onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) },
|
||||
// Hidden until the native editor and invite screens exist.
|
||||
onEditProfileClick = null,
|
||||
onInviteClick = null,
|
||||
modifier = Modifier,
|
||||
)
|
||||
},
|
||||
)
|
||||
directLogoutView.Render(state.directLogoutState)
|
||||
}
|
||||
|
||||
+7
@@ -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 {
|
||||
|
||||
+5
@@ -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<RoomListState>,
|
||||
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
|
||||
// HealthChat fork: member directory tab presenter.
|
||||
private val memberDirectoryPresenter: Presenter<MemberDirectoryState>,
|
||||
private val logoutPresenter: Presenter<DirectLogoutState>,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
@@ -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<HomeState> {
|
||||
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,
|
||||
)
|
||||
|
||||
+27
-8
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -87,6 +88,8 @@ fun HomeView(
|
||||
acceptDeclineInviteView: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
leaveRoomView: @Composable () -> Unit,
|
||||
// HealthChat fork: content of the member directory tab, rendered by features/healthchat.
|
||||
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val state: RoomListState = homeState.roomListState
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -121,6 +124,7 @@ fun HomeView(
|
||||
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
|
||||
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
memberDirectoryView = memberDirectoryView,
|
||||
)
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
RoomListSearchView(
|
||||
@@ -147,6 +151,7 @@ private fun HomeScaffold(
|
||||
onStartChatClick: () -> Unit,
|
||||
onCreateSpaceClick: () -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onRoomClick(room: RoomListRoomSummary) {
|
||||
@@ -172,6 +177,7 @@ private fun HomeScaffold(
|
||||
val hazeState = rememberHazeState()
|
||||
val roomsLazyListState = rememberLazyListState()
|
||||
val spacesLazyListState = rememberLazyListState()
|
||||
val membersLazyListState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
@@ -208,6 +214,7 @@ private fun HomeScaffold(
|
||||
val lazyListStateTarget = when (item) {
|
||||
HomeNavigationBarItem.Chats -> roomsLazyListState
|
||||
HomeNavigationBarItem.Spaces -> spacesLazyListState
|
||||
HomeNavigationBarItem.Members -> membersLazyListState
|
||||
}
|
||||
coroutineScope.launch {
|
||||
if (lazyListStateTarget.firstVisibleItemIndex > 10) {
|
||||
@@ -221,15 +228,15 @@ private fun HomeScaffold(
|
||||
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
when (state.currentHomeNavigationBarItem) {
|
||||
HomeNavigationBarItem.Chats -> {
|
||||
HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room)
|
||||
}
|
||||
HomeNavigationBarItem.Spaces -> {
|
||||
HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space)
|
||||
}
|
||||
floatingActionButton = when (state.currentHomeNavigationBarItem) {
|
||||
HomeNavigationBarItem.Chats -> {
|
||||
{ HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) }
|
||||
}
|
||||
HomeNavigationBarItem.Spaces -> {
|
||||
{ HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) }
|
||||
}
|
||||
// HealthChat fork: the member directory has no create action.
|
||||
HomeNavigationBarItem.Members -> null
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -285,6 +292,18 @@ private fun HomeScaffold(
|
||||
onExploreClick = {},
|
||||
)
|
||||
}
|
||||
// HealthChat fork: member directory tab content.
|
||||
HomeNavigationBarItem.Members -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.hazeSource(state = hazeState),
|
||||
) {
|
||||
memberDirectoryView(contentPadding, membersLazyListState)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user