mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 20:01:55 +00:00
Add member directory tab to home navigation (#3)
* feat(healthchat): add member directory tab to home navigation Third bottom-nav tab backed by the HealthChat API: searchable member cards with native start-chat, approval-pending banner, retry on failure and navigation into the native profile screens. * fix(healthchat): stop tab back handler swallowing back presses While a screen is pushed on top of Home, the still-composed tab BackHandler intercepted back presses during the transition and invisibly switched to the Chats tab. Gate it on the node's target visibility in both the parent and Home's own backstack. * fix(healthchat): satisfy detekt, ktlint and konsist rules Import alias order, composable parameter order in HomeView, and konsist presenter helper shape. * ci: pass the real repository name to the screenshot recorder recordScreenshots.yml set GITHUB_REPOSITORY from `secrets.GITHUB_REPOSITORY`. No such secret exists on this fork, so the expression expands to an empty string and overrides the built-in environment variable of the same name. recordScreenshots.sh checks it up front and exits 1 with "No repo specified", so the workflow could never record anything here. Use the `github.repository` context instead, which is always populated. Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F * Update screenshots * Revert "Update screenshots" This reverts commitb99e0d643f. * Reapply "Update screenshots" This reverts commit85bceb6d67. * test(screenshots): stop a LayoutLib teardown crash failing the shard tests Paparazzi 2.0.0-alpha05 / LayoutLib 16.2.1 races on HandlerThread startup: HandlerThread_Delegate.run calls Process.setThreadPriority, which throws NoSuchMethodError for Thread.setPosixNicenessInternal and kills the "android.bg" thread. If the test thread reaches getLooper() after that, it gets null, BackgroundThread.ensureThreadReady() NPEs, and the static initialiser of LatencyTracker fails. The JVM then marks that class erroneous for the lifetime of the fork, so every later preview showing the soft keyboard dies with NoClassDefFoundError. LayoutLibErrorFilterStatement already swallowed the bare NoSuchMethodError, but PaparazziLogger re-throws collected background errors from teardown() wrapped in a MultipleFailuresException when there is more than one, and that wrapper slipped through. A recent run reported 17 failures with zero image comparisons failing and zero delta images written. Unwrap the wrapper by reflection and swallow it only when every collected cause matches the known-bug list; anything unrecognised, and anything that cannot be decomposed, is rethrown. Also close a hole this filter had from the start. Paparazzi runs the test as `try { body } finally { teardown() }`, so a teardown error discards whatever the body threw — including a genuine snapshot mismatch. renderFailureCapture is registered inside the Paparazzi rule to record the body's exception so it wins over any teardown error, and the filter refuses to swallow anything if that rule is not registered. Two HorizontalFloatingToolbar previews have been mismatching silently behind this, and may now surface as real failures. Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
@@ -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.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 = {},
|
||||
)
|
||||
}
|
||||
+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>
|
||||
}
|
||||
+182
@@ -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<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(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user