Merge account settings into the root preference view.

This commit is contained in:
Benoit Marty
2026-09-23 15:51:09 +02:00
parent 2669ac4eae
commit c612c80b1d
14 changed files with 226 additions and 358 deletions
@@ -26,7 +26,6 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.account.PreferencesAccountNode
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
@@ -77,9 +76,6 @@ class PreferencesFlowNode(
@Parcelize
data object Root : NavTarget
@Parcelize
data object AccountSettings : NavTarget
@Parcelize
data object DeveloperSettings : NavTarget
@@ -173,14 +169,6 @@ class PreferencesFlowNode(
backstack.push(NavTarget.Labs)
}
override fun navigateToAccountSettings() {
backstack.push(NavTarget.AccountSettings)
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.AccountSettings -> {
val callback = object : PreferencesAccountNode.Callback {
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
@@ -217,7 +205,7 @@ class PreferencesFlowNode(
backstack.push(NavTarget.AccountDeactivation)
}
}
createNode<PreferencesAccountNode>(buildContext, plugins = listOf(callback))
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.DeveloperSettings -> {
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
@@ -0,0 +1,22 @@
/*
* 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.account
import io.element.android.libraries.matrix.api.user.MatrixUser
interface PreferencesAccountCallback {
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToModerationAndSafety()
fun navigateToNotificationSettings()
fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
fun startAccountDeactivationFlow()
}
@@ -1,96 +0,0 @@
/*
* 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.account
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class)
@AssistedInject
class PreferencesAccountNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesAccountPresenter,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToModerationAndSafety()
fun navigateToNotificationSettings()
fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
fun startAccountDeactivationFlow()
}
private val callback: Callback = callback()
private fun onManageAccountClick(
activity: Activity,
url: String?,
isDark: Boolean,
) {
url?.let {
activity.openUrlInChromeCustomTab(
null,
darkTheme = isDark,
url = it
)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
PreferencesAccountView(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onAvatarClick = {
// TODO
},
onOpenRageShake = callback::navigateToBugReport,
onSecureBackupClick = callback::navigateToSecureBackup,
onModerationAndSafetyClick = callback::navigateToModerationAndSafety,
onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile,
onOpenBlockedUsers = callback::navigateToBlockedUsers,
onSignOutClick = {
if (state.directLogoutState.canDoDirectSignOut) {
state.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} else {
callback.startSignOutFlow()
}
},
onDeactivateClick = callback::startAccountDeactivationFlow
)
directLogoutView.Render(state = state.directLogoutState)
}
}
@@ -7,7 +7,7 @@
package io.element.android.features.preferences.impl.account
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -15,31 +15,20 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
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.ElementPreview
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.UserHeaderView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PreferencesAccountView(
state: PreferencesAccountState,
onBackClick: () -> Unit,
onAvatarClick: (String) -> Unit,
onEditProfileClick: (MatrixUser) -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
@@ -51,63 +40,46 @@ fun PreferencesAccountView(
onDeactivateClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Box(modifier = modifier) {
PreferencePage(
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_your_account),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserHeaderView(
matrixUser = state.myUser,
onAvatarClick = onAvatarClick,
)
// 'Account settings' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onEditProfileClick = onEditProfileClick,
onOpenBlockedUsers = onOpenBlockedUsers,
)
OtherSettingsSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenRageShake = onOpenRageShake,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
}
Column(modifier = modifier) {
// 'Account settings' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers,
)
OtherSettingsSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenRageShake = onOpenRageShake,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
}
}
@Composable
private fun ManageAccountSection(
state: PreferencesAccountState,
onEditProfileClick: (MatrixUser) -> Unit,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
PreferenceCategory(
title = stringResource(CommonStrings.common_account_settings),
showTopDivider = false,
) {
ListItem(
content = { Text(stringResource(id = R.string.screen_edit_profile_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
onClick = { onEditProfileClick(state.myUser) },
)
state.accountManagementUrl?.let { url ->
ListItem(
content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
@@ -135,7 +107,6 @@ private fun ColumnScope.OtherSettingsSection(
onOpenNotificationSettings: () -> Unit,
onSecureBackupClick: () -> Unit,
) {
HorizontalDivider()
ListItem(
content = { Text(stringResource(id = R.string.screen_notification_settings_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
@@ -173,6 +144,7 @@ private fun ColumnScope.GeneralSection(
onClick = onOpenRageShake,
)
}
HorizontalDivider()
ListItem(
content = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
@@ -189,36 +161,21 @@ private fun ColumnScope.GeneralSection(
}
}
@PreviewWithLargeHeight
@PreviewsDayNight
@Composable
internal fun PreferencesAccountViewLightPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) =
ElementPreviewLight(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
internal fun PreferencesAccountViewPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) =
ElementPreview {
PreferencesAccountView(
state = state,
onModerationAndSafetyClick = {},
onOpenRageShake = {},
onSecureBackupClick = {},
onManageAccountClick = {},
onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenBlockedUsers = {},
onSignOutClick = {},
onDeactivateClick = {},
)
}
@PreviewWithLargeHeight
@Composable
internal fun PreferencesAccountViewDarkPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) =
ElementPreviewDark(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: PreferencesAccountState) {
PreferencesAccountView(
state = state,
onBackClick = {},
onAvatarClick = {},
onEditProfileClick = {},
onModerationAndSafetyClick = {},
onOpenRageShake = {},
onSecureBackupClick = {},
onManageAccountClick = {},
onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenBlockedUsers = {},
onSignOutClick = {},
onDeactivateClick = {},
)
}
@@ -0,0 +1,23 @@
/*
* 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.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.preferences.impl.account.PreferencesAccountPresenter
import io.element.android.features.preferences.impl.account.PreferencesAccountState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
interface PreferencesBindingContainer {
@Binds
fun bindPreferencesAccountPresenter(presenter: PreferencesAccountPresenter): Presenter<PreferencesAccountState>
}
@@ -8,6 +8,8 @@
package io.element.android.features.preferences.impl.root
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
@@ -16,6 +18,11 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.preferences.impl.account.PreferencesAccountCallback
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -27,8 +34,9 @@ class PreferencesRootNode(
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesRootPresenter,
private val emojiPickerRenderer: EmojiPickerRenderer,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount()
fun navigateToAnalyticsSettings()
fun navigateToAbout()
@@ -37,14 +45,30 @@ class PreferencesRootNode(
fun navigateToMediaSettings()
fun navigateToLocationSettings()
fun navigateToLabs()
fun navigateToAccountSettings()
}
private val callback: Callback = callback()
private fun onManageAccountClick(
activity: Activity,
url: String?,
isDark: Boolean,
) {
url?.let {
activity.openUrlInChromeCustomTab(
null,
darkTheme = isDark,
url = it
)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
PreferencesRootView(
state = state,
emojiPickerRenderer = emojiPickerRenderer,
@@ -58,7 +82,24 @@ class PreferencesRootNode(
onOpenLocationSettings = callback::navigateToLocationSettings,
onOpenLabs = callback::navigateToLabs,
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
onOpenAccountSettings = callback::navigateToAccountSettings,
onOpenRageShake = callback::navigateToBugReport,
onSecureBackupClick = callback::navigateToSecureBackup,
onModerationAndSafetyClick = callback::navigateToModerationAndSafety,
onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile,
onOpenBlockedUsers = callback::navigateToBlockedUsers,
onSignOutClick = {
if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) {
state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} else {
callback.startSignOutFlow()
}
},
onDeactivateClick = callback::startAccountDeactivationFlow
)
directLogoutView.Render(state = state.preferencesAccountState.directLogoutState)
}
}
@@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.preferences.impl.account.PreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.libraries.architecture.Presenter
@@ -50,6 +51,7 @@ class PreferencesRootPresenter(
private val sessionStore: SessionStore,
private val appPreferencesStore: AppPreferencesStore,
private val userStatusPresenter: Presenter<UserStatusState>,
private val preferencesAccountPresenter: Presenter<PreferencesAccountState>,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<PreferencesRootState> {
@@ -61,6 +63,7 @@ class PreferencesRootPresenter(
// Force a refresh of the profile
matrixClient.getUserProfile()
}
val preferencesAccountState = preferencesAccountPresenter.present()
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
@@ -144,6 +147,7 @@ class PreferencesRootPresenter(
return PreferencesRootState(
myUser = matrixUser.value,
userStatusState = userStatusState,
preferencesAccountState = preferencesAccountState,
theme = themeOption,
availableThemeOptions = availableThemeOptions,
version = remember { versionFormatter.get() },
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.account.PreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -21,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList
data class PreferencesRootState(
val myUser: MatrixUser,
val theme: ThemeOption,
val preferencesAccountState: PreferencesAccountState,
val availableThemeOptions: ImmutableList<ThemeOption>,
val userStatusState: UserStatusState?,
val version: String,
@@ -9,6 +9,8 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.preferences.impl.account.PreferencesAccountState
import io.element.android.features.preferences.impl.account.aPreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
@@ -56,6 +58,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(),
preferencesAccountState: PreferencesAccountState = aPreferencesAccountState(),
version: String = "Version 1.1 (1)",
isMultiAccountEnabled: Boolean = false,
isOtherAccountsSectionExpanded: Boolean = false,
@@ -70,6 +73,7 @@ fun aPreferencesRootState(
eventSink: (PreferencesRootEvent) -> Unit = {},
) = PreferencesRootState(
myUser = myUser,
preferencesAccountState = preferencesAccountState,
version = version,
isMultiAccountEnabled = isMultiAccountEnabled,
theme = theme,
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.account.PreferencesAccountView
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView
@@ -64,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.testtags.TestTags
@@ -84,7 +86,16 @@ fun PreferencesRootView(
onOpenMediaSettings: () -> Unit,
onOpenLocationSettings: () -> Unit,
onOpenLabs: () -> Unit,
onOpenAccountSettings: () -> Unit,
onEditProfileClick: (MatrixUser) -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
onOpenRageShake: () -> Unit,
onModerationAndSafetyClick: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenBlockedUsers: () -> Unit,
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -100,7 +111,7 @@ fun PreferencesRootView(
modifier = Modifier
.testTag(TestTags.settingsUserProfile)
.clickable {
onOpenAccountSettings()
onEditProfileClick(state.myUser)
},
matrixUser = state.myUser,
)
@@ -120,6 +131,19 @@ fun PreferencesRootView(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
// Account settings section
PreferencesAccountView(
state = state.preferencesAccountState,
onSecureBackupClick = onSecureBackupClick,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenRageShake = onOpenRageShake,
onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// 'App settings' section
AppSettingsSection(
state = state,
@@ -287,7 +311,6 @@ private fun AppSettingsSection(
) {
PreferenceCategory(
title = stringResource(CommonStrings.common_app_settings),
showTopDivider = false,
) {
PreferenceDropdown(
icon = CompoundIcons.DarkMode(),
@@ -411,6 +434,15 @@ private fun ContentToPreview(state: PreferencesRootState) {
onOpenLabs = {},
onOpenAbout = {},
onOpenLockScreenSettings = {},
onOpenAccountSettings = {},
onEditProfileClick = {},
onSecureBackupClick = {},
onManageAccountClick = {},
onLinkNewDeviceClick = {},
onOpenRageShake = {},
onModerationAndSafetyClick = {},
onOpenNotificationSettings = {},
onOpenBlockedUsers = {},
onSignOutClick = {},
onDeactivateClick = {},
)
}
@@ -14,61 +14,20 @@ import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.MatrixUser
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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class PreferencesAccountViewTest : RobolectricTest() {
@Test
fun `clicking on back invokes back callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
setView(
aPreferencesAccountState(),
onBackClick = callback,
)
pressBack()
}
}
@Test
fun `click on the avatar invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnceWithParam("anAvatarUrl") { callback ->
setView(
aPreferencesAccountState(
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
),
onAvatarClick = callback,
)
onNodeWithContentDescription(activity!!.getString(CommonStrings.a11y_user_avatar)).performClick()
}
}
@Test
fun `click on Edit profile invokes the expected callback`() = runAndroidComposeUiTest {
val user = aMatrixUser()
ensureCalledOnceWithParam(user) { callback ->
setView(
aPreferencesAccountState(myUser = user),
onEditProfileClick = callback,
)
clickOn(R.string.screen_edit_profile_title)
}
}
@Test
fun `click on Manage account invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnceWithParam("aUrl") { callback ->
@@ -139,7 +98,7 @@ class PreferencesAccountViewTest : RobolectricTest() {
onSecureBackupClick = callback,
)
val text = activity!!.getString(CommonStrings.common_encryption)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
onNode(hasText(text) and hasClickAction()).performClick()
}
}
@@ -157,7 +116,7 @@ class PreferencesAccountViewTest : RobolectricTest() {
onModerationAndSafetyClick = callback,
)
val text = activity!!.getString(CommonStrings.common_moderation_and_safety)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
onNode(hasText(text) and hasClickAction()).performClick()
}
}
@@ -169,7 +128,7 @@ class PreferencesAccountViewTest : RobolectricTest() {
onOpenRageShake = callback,
)
val text = activity!!.getString(CommonStrings.common_report_a_problem)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
onNode(hasText(text) and hasClickAction()).performClick()
}
}
@@ -187,7 +146,7 @@ class PreferencesAccountViewTest : RobolectricTest() {
onSignOutClick = callback,
)
val text = activity!!.getString(CommonStrings.action_signout)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
onNode(hasText(text) and hasClickAction()).performClick()
}
}
@@ -199,7 +158,7 @@ class PreferencesAccountViewTest : RobolectricTest() {
onDeactivateClick = callback,
)
val text = activity!!.getString(CommonStrings.action_delete_account)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
onNode(hasText(text) and hasClickAction()).performClick()
}
}
@@ -212,9 +171,6 @@ class PreferencesAccountViewTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: PreferencesAccountState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onAvatarClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onEditProfileClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onSecureBackupClick: () -> Unit = EnsureNeverCalled(),
onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(),
onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(),
@@ -228,9 +184,6 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
setContent {
PreferencesAccountView(
state = state,
onBackClick = onBackClick,
onAvatarClick = onAvatarClick,
onEditProfileClick = onEditProfileClick,
onSecureBackupClick = onSecureBackupClick,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
@@ -13,6 +13,7 @@ package io.element.android.features.preferences.impl.root
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme
import io.element.android.features.preferences.impl.account.aPreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.libraries.core.meta.BuildType
@@ -67,6 +68,7 @@ class PreferencesRootPresenterTest {
assertThat(initialState.isMultiAccountEnabled).isFalse()
assertThat(initialState.isOtherAccountsSectionExpanded).isTrue()
assertThat(initialState.otherSessions).isEmpty()
assertThat(initialState.preferencesAccountState).isEqualTo(aPreferencesAccountState())
val loadedState = awaitItem()
assertThat(loadedState.theme).isEqualTo(ThemeOption.System)
assertThat(loadedState.availableThemeOptions).isEqualTo(
@@ -291,6 +293,7 @@ class PreferencesRootPresenterTest {
sessionStore = sessionStore,
appPreferencesStore = appPreferencesStore,
userStatusPresenter = { aUserStatusState() },
preferencesAccountPresenter = { aPreferencesAccountState() },
sessionCoroutineScope = this,
)
}
@@ -20,15 +20,18 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
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.assertNoNodeWithText
import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed
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 io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.toImmutableList
@@ -52,13 +55,14 @@ class PreferencesRootViewTest : RobolectricTest() {
@Test
fun `click on the user row invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
val user = aMatrixUser()
ensureCalledOnceWithParam(user) { callback ->
setView(
aPreferencesRootState(
myUser = aMatrixUser(),
myUser = user,
eventSink = eventsRecorder,
),
onOpenAccountSettings = callback,
onEditProfileClick = callback,
)
onNodeWithText("Alice").performClick()
}
@@ -182,7 +186,8 @@ class PreferencesRootViewTest : RobolectricTest() {
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_appearance)
val appearance = activity!!.getString(CommonStrings.common_appearance)
onNode(hasText(appearance) and hasClickAction()).performScrollTo().performClick()
clickOn(R.string.theme_dark)
eventsRecorder.assertSingle(PreferencesRootEvent.SetTheme(ThemeOption.Dark))
}
@@ -196,8 +201,9 @@ class PreferencesRootViewTest : RobolectricTest() {
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_appearance)
onNodeWithText(activity!!.getString(R.string.theme_black)).assertExists()
val appearance = activity!!.getString(CommonStrings.common_appearance)
onNode(hasText(appearance) and hasClickAction()).performScrollTo().performClick()
assertNodeWithTextIsDisplayed(R.string.theme_black)
}
@Test
@@ -209,7 +215,8 @@ class PreferencesRootViewTest : RobolectricTest() {
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_appearance)
val appearance = activity!!.getString(CommonStrings.common_appearance)
onNode(hasText(appearance) and hasClickAction()).performScrollTo().performClick()
assertNoNodeWithText(R.string.theme_black)
}
@@ -223,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenMediaSettings = callback,
)
clickOn(CommonStrings.common_media_upload_quality)
val text = activity!!.getString(CommonStrings.common_media_upload_quality)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -237,7 +245,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenLockScreenSettings = callback,
)
clickOn(CommonStrings.common_screen_lock)
val text = activity!!.getString(CommonStrings.common_screen_lock)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -393,7 +402,16 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenMediaSettings: () -> Unit = EnsureNeverCalled(),
onOpenLocationSettings: () -> Unit = EnsureNeverCalled(),
onOpenLabs: () -> Unit = EnsureNeverCalled(),
onOpenAccountSettings: () -> Unit = EnsureNeverCalled(),
onEditProfileClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onSecureBackupClick: () -> Unit = EnsureNeverCalled(),
onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(),
onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(),
onOpenRageShake: () -> Unit = EnsureNeverCalled(),
onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(),
onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(),
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
onSignOutClick: () -> Unit = EnsureNeverCalled(),
onDeactivateClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PreferencesRootView(
@@ -408,7 +426,16 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenMediaSettings = onOpenMediaSettings,
onOpenLocationSettings = onOpenLocationSettings,
onOpenLabs = onOpenLabs,
onOpenAccountSettings = onOpenAccountSettings,
onEditProfileClick = onEditProfileClick,
onSecureBackupClick = onSecureBackupClick,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenRageShake = onOpenRageShake,
onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
}
}
@@ -1,92 +0,0 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
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.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserHeaderView(
matrixUser: MatrixUser,
onAvatarClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.UserHeader),
avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.a11y_user_avatar),
modifier = Modifier
.clip(CircleShape)
.clickable(
enabled = matrixUser.avatarUrl != null,
onClickLabel = stringResource(CommonStrings.action_view),
) {
onAvatarClick(matrixUser.avatarUrl!!)
}
)
Spacer(modifier = Modifier.height(16.dp))
matrixUser.displayName?.let { displayName ->
Text(
modifier = Modifier
.clipToBounds()
.semantics {
heading()
},
text = displayName,
style = ElementTheme.typography.fontHeadingMdRegular,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
}
CopiableTextAtom(
text = matrixUser.userId.value,
)
Spacer(modifier = Modifier.height(12.dp))
}
}
@PreviewsDayNight
@Composable
internal fun UserHeaderViewPreview() = ElementPreview {
UserHeaderView(
aMatrixUser(),
onAvatarClick = {},
)
}