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,
)
}
}