Settings UI update.

- Reorder items
- Minor UI update
- Improve the previews of the Composable
- Merge manage account and manage devices
- Add missing tests
This commit is contained in:
Benoit Marty 2026-04-16 12:39:08 +02:00 committed by Benoit Marty
parent 38d2150b85
commit 74f147a9d7
18 changed files with 733 additions and 248 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
@ -177,7 +176,6 @@ class LoggedInPresenter(
} }
private fun CoroutineScope.preloadAccountManagementUrl() = launch { private fun CoroutineScope.preloadAccountManagementUrl() = launch {
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile) matrixClient.getAccountManagementUrl(null)
matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList)
} }
} }

View file

@ -71,7 +71,7 @@ class LoggedInPresenterTest {
} }
@Test @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 accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
accountManagementUrlResult = accountManagementUrlResult, accountManagementUrlResult = accountManagementUrlResult,
@ -81,11 +81,8 @@ class LoggedInPresenterTest {
).test { ).test {
awaitItem() awaitItem()
advanceUntilIdle() advanceUntilIdle()
accountManagementUrlResult.assertions().isCalledExactly(2) accountManagementUrlResult.assertions().isCalledOnce()
.withSequence( .with(value(null))
listOf(value(AccountManagementAction.Profile)),
listOf(value(AccountManagementAction.DevicesList)),
)
} }
} }

View file

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

View file

@ -23,15 +23,16 @@ data class PreferencesRootState(
val showSecureBackup: Boolean, val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean, val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?, val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean, val canReportBug: Boolean,
val showLinkNewDevice: Boolean, val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean, val canDeactivateAccount: Boolean,
val showBlockedUsersItem: Boolean, val nbOfBlockedUsers: Int,
val showLabsItem: Boolean, val showLabsItem: Boolean,
val directLogoutState: DirectLogoutState, val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?, 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 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.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser 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.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList 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( fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(), myUser: MatrixUser = aMatrixUser(),
version: String = "Version 1.1 (1)",
deviceId: DeviceId? = null,
isMultiAccountEnabled: Boolean = false,
otherSessions: List<MatrixUser> = emptyList(), 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( ) = PreferencesRootState(
myUser = myUser, myUser = myUser,
version = "Version 1.1 (1)", version = version,
deviceId = DeviceId("ILAKNDNASDLK"), deviceId = deviceId,
isMultiAccountEnabled = true, isMultiAccountEnabled = isMultiAccountEnabled,
otherSessions = otherSessions.toImmutableList(), otherSessions = otherSessions.toImmutableList(),
showSecureBackup = true, showSecureBackup = showSecureBackup,
showSecureBackupBadge = true, showSecureBackupBadge = showSecureBackupBadge,
accountManagementUrl = "aUrl", accountManagementUrl = accountManagementUrl,
devicesManagementUrl = "anOtherUrl", canReportBug = canReportBug,
showAnalyticsSettings = true, showLinkNewDevice = showLinkNewDevice,
showLinkNewDevice = true, showAnalyticsSettings = showAnalyticsSettings,
canReportBug = true, showDeveloperSettings = showDeveloperSettings,
showDeveloperSettings = true, canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = true, nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = true, showLabsItem = showLabsItem,
canDeactivateAccount = true, directLogoutState = directLogoutState,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), snackbarMessage = snackbarMessage,
directLogoutState = aDirectLogoutState(),
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -9,7 +9,6 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage 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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource 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.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text 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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState 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.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser 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.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -82,22 +78,17 @@ fun PreferencesRootView(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onOpenUserProfile(state.myUser) onOpenUserProfile(state.myUser)
}, },
user = state.myUser, matrixUser = state.myUser,
) )
if (state.isMultiAccountEnabled) { if (state.isMultiAccountEnabled) {
MultiAccountSection( MultiAccountSection(
state = state, state = state,
onAddAccountClick = onAddAccountClick, onAddAccountClick = onAddAccountClick,
) )
} else {
HorizontalDivider()
} }
// 'Manage my app' section // User status will be added here
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// 'Account' section // 'Account' section
ManageAccountSection( ManageAccountSection(
state = state, state = state,
@ -105,6 +96,13 @@ fun PreferencesRootView(
onLinkNewDeviceClick = onLinkNewDeviceClick, onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers onOpenBlockedUsers = onOpenBlockedUsers
) )
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section // General section
GeneralSection( GeneralSection(
@ -118,12 +116,12 @@ fun PreferencesRootView(
onSignOutClick = onSignOutClick, onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick, onDeactivateClick = onDeactivateClick,
) )
// Version
Footer( Footer(
version = state.version, version = state.version,
deviceId = state.deviceId, deviceId = state.deviceId,
onClick = if (!state.showDeveloperSettings) { onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvents.OnVersionInfoClick) } { state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else { } else {
null null
} }
@ -142,13 +140,15 @@ private fun ColumnScope.MultiAccountSection(
) )
state.otherSessions.forEach { matrixUser -> state.otherSessions.forEach { matrixUser ->
MatrixUserRow( MatrixUserRow(
modifier = Modifier.clickable { modifier = Modifier
state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId)) .clickable {
}, state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser, matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem, avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
) )
HorizontalDivider()
} }
ListItem( ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
@ -198,6 +198,14 @@ private fun ColumnScope.ManageAccountSection(
onLinkNewDeviceClick: () -> Unit, onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> 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) { if (state.showLinkNewDevice) {
ListItem( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
@ -205,33 +213,15 @@ private fun ColumnScope.ManageAccountSection(
onClick = onLinkNewDeviceClick, 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) { if (state.showBlockedUsersItem) {
ListItem( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers, onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()),
) )
} }
if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) {
if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) {
HorizontalDivider() HorizontalDivider()
} }
} }
@ -248,6 +238,18 @@ private fun ColumnScope.GeneralSection(
onSignOutClick: () -> Unit, onSignOutClick: () -> Unit,
onDeactivateClick: () -> 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( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, headlineContent = { Text(stringResource(id = CommonStrings.common_about)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
@ -267,30 +269,17 @@ private fun ColumnScope.GeneralSection(
onClick = onOpenAnalytics, onClick = onOpenAnalytics,
) )
} }
ListItem( HorizontalDivider()
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( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) }, headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
style = ListItemStyle.Destructive, style = ListItemStyle.Destructive,
onClick = onSignOutClick, onClick = onSignOutClick,
) )
if (state.canDeactivateAccount) { if (state.canDeactivateAccount) {
ListItem( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) }, 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, style = ListItemStyle.Destructive,
onClick = onDeactivateClick, onClick = onDeactivateClick,
) )
@ -319,9 +308,8 @@ private fun ColumnScope.Footer(
Text( Text(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)
.padding(top = 16.dp)
.clickable(enabled = onClick != null, onClick = onClick ?: {}) .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, textAlign = TextAlign.Center,
text = text, text = text,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
@ -340,19 +328,23 @@ private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
@PreviewWithLargeHeight @PreviewWithLargeHeight
@Composable @Composable
internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = internal fun PreferencesRootViewLightPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewLight { ContentToPreview(matrixUser) } ElementPreviewLight(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@PreviewWithLargeHeight @PreviewWithLargeHeight
@Composable @Composable
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewDark { ContentToPreview(matrixUser) } ElementPreviewDark(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@ExcludeFromCoverage @ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview(matrixUser: MatrixUser) { private fun ContentToPreview(state: PreferencesRootState) {
PreferencesRootView( PreferencesRootView(
state = aPreferencesRootState(myUser = matrixUser), state = state,
onBackClick = {}, onBackClick = {},
onAddAccountClick = {}, onAddAccountClick = {},
onOpenAnalytics = {}, onOpenAnalytics = {},
@ -372,16 +364,3 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onDeactivateClick = {}, 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.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser 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.MatrixUserHeader
import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
@Composable @Composable
fun UserPreferences( fun UserPreferences(
user: MatrixUser?, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
MatrixUserHeader( MatrixUserHeader(
modifier = modifier, modifier = modifier,
matrixUser = user matrixUser = matrixUser,
) )
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = ElementPreview { internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
UserPreferences(matrixUser) 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.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID 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_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.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -73,6 +77,7 @@ class PreferencesRootPresenterTest {
assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.version).isEqualTo("A Version")
assertThat(initialState.isMultiAccountEnabled).isFalse() assertThat(initialState.isMultiAccountEnabled).isFalse()
assertThat(initialState.otherSessions).isEmpty() assertThat(initialState.otherSessions).isEmpty()
assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem() val loadedState = awaitItem()
assertThat(loadedState.myUser).isEqualTo( assertThat(loadedState.myUser).isEqualTo(
MatrixUser( MatrixUser(
@ -81,27 +86,21 @@ class PreferencesRootPresenterTest {
avatarUrl = AN_AVATAR_URL avatarUrl = AN_AVATAR_URL
) )
) )
assertThat(initialState.version).isEqualTo("A Version")
assertThat(loadedState.showSecureBackup).isFalse() assertThat(loadedState.showSecureBackup).isFalse()
assertThat(loadedState.showSecureBackupBadge).isFalse() assertThat(loadedState.showSecureBackupBadge).isFalse()
assertThat(loadedState.accountManagementUrl).isNull() assertThat(loadedState.accountManagementUrl).isNull()
assertThat(loadedState.devicesManagementUrl).isNull()
assertThat(loadedState.showAnalyticsSettings).isFalse() assertThat(loadedState.showAnalyticsSettings).isFalse()
assertThat(loadedState.showLinkNewDevice).isFalse() assertThat(loadedState.showLinkNewDevice).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue() assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue() assertThat(loadedState.canReportBug).isTrue()
assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0)
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState()) assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(loadedState.snackbarMessage).isNull() assertThat(loadedState.snackbarMessage).isNull()
skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
accountManagementUrlResult.assertions().isCalledExactly(2) accountManagementUrlResult.assertions().isCalledOnce()
.withSequence( .with(value(null))
listOf(value(AccountManagementAction.Profile)), assertThat(finalState.accountManagementUrl).isEqualTo("null url")
listOf(value(AccountManagementAction.DevicesList)),
)
assertThat(finalState.accountManagementUrl).isEqualTo("Profile url")
assertThat(finalState.devicesManagementUrl).isEqualTo("DevicesList 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 @Test
fun `present - secure backup badge`() = runTest { fun `present - secure backup badge`() = runTest {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
@ -181,12 +196,36 @@ class PreferencesRootPresenterTest {
val loadedState = awaitFirstItem() val loadedState = awaitFirstItem()
repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) { repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) {
assertThat(loadedState.showDeveloperSettings).isFalse() assertThat(loadedState.showDeveloperSettings).isFalse()
loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick) loadedState.eventSink(PreferencesRootEvent.OnVersionInfoClick)
} }
assertThat(awaitItem().showDeveloperSettings).isTrue() 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 @Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPresenter( 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), RoomSelectRoomListItem(36.dp),
UserPreference(56.dp), UserPreference(52.dp),
UserHeader(96.dp), UserHeader(96.dp),
UserListItem(36.dp), UserListItem(36.dp),

View file

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

View file

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

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -34,51 +35,34 @@ import io.element.android.libraries.matrix.ui.model.getBestName
@Composable @Composable
fun MatrixUserHeader( 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, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// onClick: () -> Unit,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
// .clickable(onClick = onClick)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Avatar( Avatar(
modifier = Modifier modifier = Modifier
.padding(vertical = 12.dp), .padding(vertical = 7.dp),
avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference), avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference),
avatarType = AvatarType.User, avatarType = AvatarType.User,
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(13.dp))
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// Name // Name
Text( Text(
modifier = Modifier.clipToBounds(), modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(), text = matrixUser.getBestName(),
maxLines = 1, maxLines = 1,
style = ElementTheme.typography.fontHeadingSmMedium, style = ElementTheme.typography.fontHeadingMdRegular,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary, 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?> { open class MatrixUserWithAvatarProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?> override val values: Sequence<MatrixUser?>
get() = sequenceOf( get() = sequenceOf(

View file

@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter 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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -23,12 +25,14 @@ fun MatrixUserRow(
matrixUser: MatrixUser, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem, avatarSize: AvatarSize = AvatarSize.UserListItem,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null,
) = UserRow( ) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize), avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(), name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier, modifier = modifier,
verticalSpaceWidth = verticalSpaceWidth,
trailingContent = trailingContent, 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -31,22 +34,22 @@ internal fun UserRow(
subtext: String?, subtext: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar( Avatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
) )
Spacer(modifier = Modifier.width(verticalSpaceWidth))
Column( Column(
modifier = Modifier modifier = Modifier.weight(1f),
.padding(start = 12.dp)
.weight(1f),
) { ) {
// Name // Name
Text( Text(