Merge pull request #6602 from element-hq/feature/bma/updateSettingsUI

Settings UI update.
This commit is contained in:
Benoit Marty 2026-04-17 11:30:08 +02:00 committed by GitHub
commit 47a430978f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 781 additions and 290 deletions

View file

@ -31,7 +31,6 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
@ -177,7 +176,6 @@ class LoggedInPresenter(
}
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList)
matrixClient.getAccountManagementUrl(null)
}
}

View file

@ -71,7 +71,7 @@ class LoggedInPresenterTest {
}
@Test
fun `present - ensure that account urls are preloaded`() = runTest {
fun `present - ensure that account url is preloaded`() = runTest {
val accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = accountManagementUrlResult,
@ -81,11 +81,8 @@ class LoggedInPresenterTest {
).test {
awaitItem()
advanceUntilIdle()
accountManagementUrlResult.assertions().isCalledExactly(2)
.withSequence(
listOf(value(AccountManagementAction.Profile)),
listOf(value(AccountManagementAction.DevicesList)),
)
accountManagementUrlResult.assertions().isCalledOnce()
.with(value(null))
}
}

View file

@ -10,7 +10,7 @@ package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvents {
data object OnVersionInfoClick : PreferencesRootEvents
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
}

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
@ -99,9 +98,6 @@ class PreferencesRootPresenter(
val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
val devicesManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
var canDeactivateAccount by remember {
mutableStateOf(false)
}
@ -110,9 +106,9 @@ class PreferencesRootPresenter(
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
val showBlockedUsersItem by produceState(initialValue = false) {
val nbOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.isNotEmpty() }
.onEach { value = it.size }
.launchIn(this)
}
@ -121,17 +117,17 @@ class PreferencesRootPresenter(
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
initAccountManagementUrl(accountManagementUrl)
}
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
fun handleEvent(event: PreferencesRootEvents) {
fun handleEvent(event: PreferencesRootEvent) {
when (event) {
is PreferencesRootEvents.OnVersionInfoClick -> {
is PreferencesRootEvent.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
}
is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch {
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
}
@ -146,13 +142,12 @@ class PreferencesRootPresenter(
showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
@ -162,9 +157,7 @@ class PreferencesRootPresenter(
private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>,
devicesManagementUrl: MutableState<String?>,
) = launch {
accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull()
devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList).getOrNull()
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null).getOrNull()
}
}

View file

@ -23,15 +23,16 @@ data class PreferencesRootState(
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean,
val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
val showBlockedUsersItem: Boolean,
val nbOfBlockedUsers: Int,
val showLabsItem: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvents) -> Unit,
)
val eventSink: (PreferencesRootEvent) -> Unit,
) {
val showBlockedUsersItem = nbOfBlockedUsers > 0
}

View file

@ -8,36 +8,103 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRootState> {
override val values: Sequence<PreferencesRootState>
get() = sequenceOf(
// Nominal state, that a regular user will see if multi account is enabled
aPreferencesRootState(
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)",
deviceId = DeviceId("ILAKNDNASDLK"),
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1),
showSecureBackup = true,
accountManagementUrl = "aUrl",
canReportBug = true,
showLinkNewDevice = true,
showAnalyticsSettings = true,
canDeactivateAccount = false,
nbOfBlockedUsers = 3,
showLabsItem = true,
),
aPreferencesRootState(
myUser = aMatrixUser(displayName = null),
isMultiAccountEnabled = true,
showSecureBackup = true,
canDeactivateAccount = true,
),
aPreferencesRootState(
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(3),
accountManagementUrl = "aUrl",
showSecureBackup = true,
showSecureBackupBadge = true,
),
aPreferencesRootState(
deviceId = DeviceId("ILAKNDNASDLK"),
showLabsItem = true,
canReportBug = true,
nbOfBlockedUsers = 3,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
),
aPreferencesRootState(
showLinkNewDevice = true,
showAnalyticsSettings = true,
showDeveloperSettings = true,
canDeactivateAccount = true,
),
// Minimal state
aPreferencesRootState(),
)
}
fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(),
version: String = "Version 1.1 (1)",
deviceId: DeviceId? = null,
isMultiAccountEnabled: Boolean = false,
otherSessions: List<MatrixUser> = emptyList(),
eventSink: (PreferencesRootEvents) -> Unit = { _ -> },
showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false,
accountManagementUrl: String? = null,
canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false,
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
canDeactivateAccount: Boolean = false,
nbOfBlockedUsers: Int = 0,
showLabsItem: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
) = PreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)",
deviceId = DeviceId("ILAKNDNASDLK"),
isMultiAccountEnabled = true,
version = version,
deviceId = deviceId,
isMultiAccountEnabled = isMultiAccountEnabled,
otherSessions = otherSessions.toImmutableList(),
showSecureBackup = true,
showSecureBackupBadge = true,
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
showLabsItem = true,
canDeactivateAccount = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),
showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge,
accountManagementUrl = accountManagementUrl,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showAnalyticsSettings = showAnalyticsSettings,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)

View file

@ -9,7 +9,6 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
@ -28,23 +27,20 @@ import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -82,22 +78,17 @@ fun PreferencesRootView(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
user = state.myUser,
matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
} else {
HorizontalDivider()
}
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// User status will be added here
// 'Account' section
ManageAccountSection(
state = state,
@ -105,6 +96,13 @@ fun PreferencesRootView(
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
@ -118,12 +116,12 @@ fun PreferencesRootView(
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
deviceId = state.deviceId,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvents.OnVersionInfoClick) }
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
@ -142,13 +140,15 @@ private fun ColumnScope.MultiAccountSection(
)
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier.clickable {
state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId))
},
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
)
HorizontalDivider()
}
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
@ -198,6 +198,14 @@ private fun ColumnScope.ManageAccountSection(
onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showLinkNewDevice) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
@ -205,33 +213,15 @@ private fun ColumnScope.ManageAccountSection(
onClick = onLinkNewDeviceClick,
)
}
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
state.devicesManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showBlockedUsersItem) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()),
)
}
if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) {
if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) {
HorizontalDivider()
}
}
@ -248,6 +238,18 @@ private fun ColumnScope.GeneralSection(
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_about)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
@ -267,30 +269,17 @@ private fun ColumnScope.GeneralSection(
onClick = onOpenAnalytics,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
HorizontalDivider()
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
style = ListItemStyle.Destructive,
onClick = onSignOutClick,
)
if (state.canDeactivateAccount) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Warning())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
style = ListItemStyle.Destructive,
onClick = onDeactivateClick,
)
@ -319,9 +308,8 @@ private fun ColumnScope.Footer(
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 24.dp),
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp),
textAlign = TextAlign.Center,
text = text,
style = ElementTheme.typography.fontBodySmRegular,
@ -340,19 +328,23 @@ private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
@PreviewWithLargeHeight
@Composable
internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewLight { ContentToPreview(matrixUser) }
internal fun PreferencesRootViewLightPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewLight(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewDark { ContentToPreview(matrixUser) }
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewDark(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(matrixUser: MatrixUser) {
private fun ContentToPreview(state: PreferencesRootState) {
PreferencesRootView(
state = aPreferencesRootState(myUser = matrixUser),
state = state,
onBackClick = {},
onAddAccountClick = {},
onOpenAnalytics = {},
@ -372,16 +364,3 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onDeactivateClick = {},
)
}
@PreviewsDayNight
@Composable
internal fun MultiAccountSectionPreview() = ElementPreview {
Column {
MultiAccountSection(
state = aPreferencesRootState(
otherSessions = aMatrixUserList(),
),
onAddAccountClick = {},
)
}
}

View file

@ -15,21 +15,21 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserHeader
import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
@Composable
fun UserPreferences(
user: MatrixUser?,
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
) {
MatrixUserHeader(
modifier = modifier,
matrixUser = user
matrixUser = matrixUser,
)
}
@PreviewsDayNight
@Composable
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = ElementPreview {
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
UserPreferences(matrixUser)
}

View file

@ -28,6 +28,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
@ -40,7 +42,9 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -73,6 +77,7 @@ class PreferencesRootPresenterTest {
assertThat(initialState.version).isEqualTo("A Version")
assertThat(initialState.isMultiAccountEnabled).isFalse()
assertThat(initialState.otherSessions).isEmpty()
assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem()
assertThat(loadedState.myUser).isEqualTo(
MatrixUser(
@ -81,27 +86,21 @@ class PreferencesRootPresenterTest {
avatarUrl = AN_AVATAR_URL
)
)
assertThat(initialState.version).isEqualTo("A Version")
assertThat(loadedState.showSecureBackup).isFalse()
assertThat(loadedState.showSecureBackupBadge).isFalse()
assertThat(loadedState.accountManagementUrl).isNull()
assertThat(loadedState.devicesManagementUrl).isNull()
assertThat(loadedState.showAnalyticsSettings).isFalse()
assertThat(loadedState.showLinkNewDevice).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0)
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(loadedState.snackbarMessage).isNull()
skipItems(1)
val finalState = awaitItem()
accountManagementUrlResult.assertions().isCalledExactly(2)
.withSequence(
listOf(value(AccountManagementAction.Profile)),
listOf(value(AccountManagementAction.DevicesList)),
)
assertThat(finalState.accountManagementUrl).isEqualTo("Profile url")
assertThat(finalState.devicesManagementUrl).isEqualTo("DevicesList url")
accountManagementUrlResult.assertions().isCalledOnce()
.with(value(null))
assertThat(finalState.accountManagementUrl).isEqualTo("null url")
}
}
@ -121,6 +120,22 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - number of blocked users`() = runTest {
val matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success("") },
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)),
)
createPresenter(
matrixClient = matrixClient,
).test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.nbOfBlockedUsers).isEqualTo(2)
}
}
@Test
fun `present - secure backup badge`() = runTest {
val matrixClient = FakeMatrixClient(
@ -181,12 +196,36 @@ class PreferencesRootPresenterTest {
val loadedState = awaitFirstItem()
repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) {
assertThat(loadedState.showDeveloperSettings).isFalse()
loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick)
loadedState.eventSink(PreferencesRootEvent.OnVersionInfoClick)
}
assertThat(awaitItem().showDeveloperSettings).isTrue()
}
}
@Test
fun `present - switch session invoke method on the session store`() = runTest {
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
setLatestSessionResult = setLatestSessionResult,
)
createPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
sessionStore = sessionStore,
).test {
val loadedState = awaitFirstItem()
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPresenter(

View file

@ -0,0 +1,483 @@
/*
* 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.preferences.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
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.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PreferencesRootViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes back callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
rule.pressBack()
}
}
@Test
fun `click on User profile invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
val user = aMatrixUser()
ensureCalledOnceWithParam(user) { callback ->
rule.setView(
aPreferencesRootState(
myUser = user,
eventSink = eventsRecorder,
),
onOpenUserProfile = callback,
)
rule.onNodeWithText("Alice").performClick()
}
}
@Test
fun `clicking on other session sends a SwitchToSession`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
rule.setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
)
rule.onNodeWithText("Bob").performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
}
@Test
fun `click on Add account invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
eventSink = eventsRecorder,
),
onAddAccountClick = callback,
)
rule.clickOn(CommonStrings.common_add_another_account)
}
}
@Test
fun `when multi account is not enabled, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
isMultiAccountEnabled = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_add_another_account)).assertDoesNotExist()
}
@Test
fun `click on Encryption invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showSecureBackup = true,
eventSink = eventsRecorder,
),
onSecureBackupClick = callback,
)
rule.clickOn(CommonStrings.common_encryption)
}
}
@Test
fun `when showSecureBackup is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showSecureBackup = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_encryption)).assertDoesNotExist()
}
@Test
fun `click on Manage account invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnceWithParam("aUrl") { callback ->
rule.setView(
aPreferencesRootState(
accountManagementUrl = "aUrl",
eventSink = eventsRecorder,
),
onManageAccountClick = callback,
)
rule.clickOn(CommonStrings.action_manage_account_and_devices)
}
}
@Test
fun `when accountManagementUrl is null, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
accountManagementUrl = null,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist()
}
@Test
fun `click on Link new devices invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showLinkNewDevice = true,
eventSink = eventsRecorder,
),
onLinkNewDeviceClick = callback,
)
rule.clickOn(CommonStrings.common_link_new_device)
}
}
@Test
fun `when showLinkNewDevice is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showLinkNewDevice = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_link_new_device)).assertDoesNotExist()
}
@Test
fun `click on Analytics invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showAnalyticsSettings = true,
eventSink = eventsRecorder,
),
onOpenAnalytics = callback,
)
rule.clickOn(CommonStrings.common_analytics)
}
}
@Test
fun `when showAnalyticsSettings is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showAnalyticsSettings = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_analytics)).assertDoesNotExist()
}
@Test
fun `click on Report a problem invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
canReportBug = true,
eventSink = eventsRecorder,
),
onOpenRageShake = callback,
)
rule.clickOn(CommonStrings.common_report_a_problem)
}
}
@Test
fun `when canReportBug is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
canReportBug = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist()
}
@Test
fun `click on Screen lock invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenLockScreenSettings = callback,
)
rule.clickOn(CommonStrings.common_screen_lock)
}
}
@Test
fun `click on About invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenAbout = callback,
)
rule.clickOn(CommonStrings.common_about)
}
}
@Test
fun `click on Developer settings invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showDeveloperSettings = true,
eventSink = eventsRecorder,
),
onOpenDeveloperSettings = callback,
)
rule.clickOn(CommonStrings.common_developer_options)
}
}
@Test
fun `when showDeveloperSettings is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showDeveloperSettings = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_developer_options)).assertDoesNotExist()
}
@Test
fun `click on Advanced settings invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenAdvancedSettings = callback,
)
rule.clickOn(CommonStrings.common_advanced_settings)
}
}
@Test
fun `click on Labs invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showLabsItem = true,
eventSink = eventsRecorder,
),
onOpenLabs = callback,
)
rule.clickOn(R.string.screen_labs_title)
}
}
@Test
fun `when showLabsItem is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showLabsItem = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(R.string.screen_labs_title)).assertDoesNotExist()
}
@Test
fun `click on Notification invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenNotificationSettings = callback,
)
rule.clickOn(R.string.screen_notification_settings_title)
}
}
@Test
fun `click on Blocked users invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
nbOfBlockedUsers = 1,
eventSink = eventsRecorder,
),
onOpenBlockedUsers = callback,
)
rule.clickOn(CommonStrings.common_blocked_users)
}
}
@Test
fun `when nbOfBlockedUsers is 0, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
nbOfBlockedUsers = 0,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_blocked_users)).assertDoesNotExist()
}
@Test
fun `click on Remove this device invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onSignOutClick = callback,
)
rule.clickOn(CommonStrings.action_signout)
}
}
@Test
fun `click on Deactivate invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
canDeactivateAccount = true,
eventSink = eventsRecorder,
),
onDeactivateClick = callback,
)
rule.clickOn(CommonStrings.action_deactivate_account)
}
}
@Test
fun `when canDeactivateAccount is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
canDeactivateAccount = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_deactivate_account)).assertDoesNotExist()
}
@Test
fun `clicking on version sends a PreferencesRootEvents`() {
val version = "VERSION"
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
rule.setView(
aPreferencesRootState(
version = version,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(version).performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
state: PreferencesRootState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
onSecureBackupClick: () -> Unit = EnsureNeverCalled(),
onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(),
onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(),
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
onOpenRageShake: () -> Unit = EnsureNeverCalled(),
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
onOpenAbout: () -> Unit = EnsureNeverCalled(),
onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(),
onOpenAdvancedSettings: () -> Unit = EnsureNeverCalled(),
onOpenLabs: () -> Unit = EnsureNeverCalled(),
onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(),
onOpenUserProfile: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
onSignOutClick: () -> Unit = EnsureNeverCalled(),
onDeactivateClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PreferencesRootView(
state = state,
onBackClick = onBackClick,
onAddAccountClick = onAddAccountClick,
onSecureBackupClick = onSecureBackupClick,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onOpenAbout = onOpenAbout,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenLabs = onOpenLabs,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenUserProfile = onOpenUserProfile,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
}
}

View file

@ -24,7 +24,7 @@ enum class AvatarSize(val dp: Dp) {
RoomSelectRoomListItem(36.dp),
UserPreference(56.dp),
UserPreference(52.dp),
UserHeader(96.dp),
UserListItem(36.dp),

View file

@ -8,16 +8,21 @@
package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun ElementPreviewDark(
showBackground: Boolean = true,
content: @Composable () -> Unit
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit,
) {
ElementPreview(
darkTheme = true,
showBackground = showBackground,
content = content
drawableFallbackForImages = drawableFallbackForImages,
content = content,
)
}

View file

@ -8,16 +8,21 @@
package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun ElementPreviewLight(
showBackground: Boolean = true,
content: @Composable () -> Unit
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit,
) {
ElementPreview(
darkTheme = false,
showBackground = showBackground,
content = content
drawableFallbackForImages = drawableFallbackForImages,
content = content,
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -34,51 +35,34 @@ import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun MatrixUserHeader(
matrixUser: MatrixUser?,
modifier: Modifier = Modifier,
// TODO handle click on this item, to let the user be able to update their profile.
// onClick: () -> Unit,
) {
if (matrixUser == null) {
MatrixUserHeaderPlaceholder(modifier = modifier)
} else {
MatrixUserHeaderContent(
matrixUser = matrixUser,
modifier = modifier,
// onClick = onClick
)
}
}
@Composable
private fun MatrixUserHeaderContent(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
// onClick: () -> Unit,
) {
Row(
modifier = modifier
// .clickable(onClick = onClick)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier
.padding(vertical = 12.dp),
.padding(vertical = 7.dp),
avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(13.dp))
Column(
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(),
maxLines = 1,
style = ElementTheme.typography.fontHeadingSmMedium,
style = ElementTheme.typography.fontHeadingMdRegular,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
)

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
@Composable
fun MatrixUserHeaderPlaceholder(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.padding(vertical = 12.dp)
.size(AvatarSize.UserPreference.dp)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
PlaceholderAtom(width = 80.dp, height = 7.dp)
Spacer(modifier = Modifier.height(16.dp))
PlaceholderAtom(width = 180.dp, height = 6.dp)
}
}
}
@PreviewsDayNight
@Composable
internal fun MatrixUserHeaderPlaceholderPreview() = ElementPreview {
MatrixUserHeaderPlaceholder()
}

View file

@ -20,15 +20,6 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
)
}
open class MatrixUserWithNullProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(
aMatrixUser(),
aMatrixUser(displayName = null),
null,
)
}
open class MatrixUserWithAvatarProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(

View file

@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -23,12 +25,14 @@ fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
verticalSpaceWidth = verticalSpaceWidth,
trailingContent = trailingContent,
)

View file

@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.ui.components
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -31,22 +34,22 @@ internal fun UserRow(
subtext: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(verticalSpaceWidth))
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f),
modifier = Modifier.weight(1f),
) {
// Name
Text(

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df19ce5a967143e2cb6d1fe021663f72e36f20c32e912894a2fbad628f03c3e5
size 53561

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8fd24e865907b5c9240829710a910e445954bef9b8575f5115a52837e00d817f
size 54591

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:369c835c46e19d3e3171add57055624cf672a9d34109e6c831e0c1bce234c605
size 39513
oid sha256:712c1fca10ed7655634d300c03615c6c4dd2f71b74c178398d72fa0427f0d766
size 41537

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d9f6763de5b844eeace37bedb25b125976625394d69d7843eedb26319e926aa
size 39316
oid sha256:da47d339d9b8712aa13c394482f8aa5d2e1fb4fcb8eb10df473394bfec1ef507
size 25980

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:324ce7d935816d87fea7b70bc7ebaacb0ac1d007b08dba85f03c03a1f045e450
size 36764

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9176ee2b5b78032639d9f51f7680d82f7d3ca916fb587d914f12d075382d65f0
size 27077

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:821a13ed4effd98ad459e3697a33d9d42500d7f1f46115a97c9b7444303a3bb2
size 27645

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e457b722fe403205f1394e7347dedb3aba308e48c979ea002399425c9a130fd2
size 20667

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dce8486726293027aedcdd2e67d10a39a1a2c439ca67d81ae247b60119675ada
size 40385
oid sha256:2c149288a8ef258f65292f673b9a15ea34910db6d3bfe2402b2a3264227f2b0d
size 42547

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:93ee581f59c79e03b9c9311765da4c828c5009d14e92f7cca9bbcee418fdfc63
size 40442
oid sha256:7ecf19446d8a0cf57431f13cbac9331ff72c93637c1cd1b442ed6330566debb2
size 26879

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00c7c42f1e6dde16916ae12a889b728c0fb321aa2c3fc6af80ec01d99a3af7a6
size 37164

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74a4913734d0648115d5056052fa2de8a839bb5a4d2dfdaa3d8ed5f0eef2793d
size 27728

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6c20d715bfa287ca0fa78bab1166ff0370b5124455c87f1956e7dc3cb9b3d36
size 28253

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d5b500d6275bc6ead5e8644b1afb1a0a829e91d4912444e5e0d431322343855
size 20700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5ccaa7f88a9e46bdc526bfe3d5c2163bf3d963c0661179db97f62559edfd3189
size 11042
oid sha256:0805bac4bf9e1c5bb16b4f81b004bcc952563eef98101d8c9c6e856a414977d0
size 11219

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f37e1587ba12f9b6326b5b7398982fc663ca913da8c0ee83dfbd5e9decbd4362
size 10906
oid sha256:b61c5a72e8e63a1775d20717c78d8e68e46c7c22b8e4ab8c24154dc3d48d7d0e
size 11428

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b494ecdb4962772dd548339a4ac57be40b273b513697ed6d039d1c905617d54f
size 4987

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcb0063babe7091368af6b5bc7e8929c54ea879bd78043d9128db2dcea9d79fa
size 11191
oid sha256:070163694fe10b465f2903ce2cc88f9ffc67d9489aa5a4e204cd087fd02b8642
size 11281

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f34e63a88464ddc817d1ffe0324352199ae1821dfa846cceac129a656ece2eb6
size 10911
oid sha256:da4e1512cd7a58ede774755b6e3ac427e90c437bbbadaea8479506af776999c3
size 11323

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0618a9f769e15b4e682d763224cc1fe0abf62c58f3b9a6b4059153f8805671e
size 4740

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b494ecdb4962772dd548339a4ac57be40b273b513697ed6d039d1c905617d54f
size 4987

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0618a9f769e15b4e682d763224cc1fe0abf62c58f3b9a6b4059153f8805671e
size 4740

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5ccaa7f88a9e46bdc526bfe3d5c2163bf3d963c0661179db97f62559edfd3189
size 11042
oid sha256:0805bac4bf9e1c5bb16b4f81b004bcc952563eef98101d8c9c6e856a414977d0
size 11219

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f37e1587ba12f9b6326b5b7398982fc663ca913da8c0ee83dfbd5e9decbd4362
size 10906
oid sha256:b61c5a72e8e63a1775d20717c78d8e68e46c7c22b8e4ab8c24154dc3d48d7d0e
size 11428

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcb0063babe7091368af6b5bc7e8929c54ea879bd78043d9128db2dcea9d79fa
size 11191
oid sha256:070163694fe10b465f2903ce2cc88f9ffc67d9489aa5a4e204cd087fd02b8642
size 11281

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f34e63a88464ddc817d1ffe0324352199ae1821dfa846cceac129a656ece2eb6
size 10911
oid sha256:da4e1512cd7a58ede774755b6e3ac427e90c437bbbadaea8479506af776999c3
size 11323