From c612c80b1d112fc634c0570267070e4286ae5157 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 23 Sep 2026 15:51:09 +0200 Subject: [PATCH] Merge account settings into the root preference view. --- .../preferences/impl/PreferencesFlowNode.kt | 14 +- .../account/PreferencesAccountCallback.kt | 22 +++ .../impl/account/PreferencesAccountNode.kt | 96 ------------- .../impl/account/PreferencesAccountView.kt | 131 ++++++------------ .../impl/di/PreferencesBindingContainer.kt | 23 +++ .../impl/root/PreferencesRootNode.kt | 47 ++++++- .../impl/root/PreferencesRootPresenter.kt | 4 + .../impl/root/PreferencesRootState.kt | 2 + .../root/PreferencesRootStatePreviewParam.kt | 4 + .../impl/root/PreferencesRootView.kt | 40 +++++- .../account/PreferencesAccountViewTest.kt | 57 +------- .../impl/root/PreferencesRootPresenterTest.kt | 3 + .../impl/root/PreferencesRootViewTest.kt | 49 +++++-- .../matrix/ui/components/UserHeaderView.kt | 92 ------------ 14 files changed, 226 insertions(+), 358 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt delete mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountNode.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt delete mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserHeaderView.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index f8268cb6d9..4d6c2b772d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -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(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(buildContext, plugins = listOf(callback)) + createNode(buildContext, plugins = listOf(callback)) } NavTarget.DeveloperSettings -> { val developerSettingsCallback = object : DeveloperSettingsNode.Callback { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt new file mode 100644 index 0000000000..2d770898d8 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt @@ -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() +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountNode.kt deleted file mode 100644 index 08489b57bf..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountNode.kt +++ /dev/null @@ -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, - 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) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt index b6df91c7ad..630d667e5a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt @@ -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 = {}, - ) -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt new file mode 100644 index 0000000000..96572f1d39 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt @@ -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 +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 6fdb568156..a498cc40f2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -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, 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) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index c72def17a6..6bada34e93 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -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, + private val preferencesAccountPresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : Presenter { @@ -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() }, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 195d7589f7..67b75a84a8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -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, val userStatusState: UserStatusState?, val version: String, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 5ce7fafa00..57b7493dd5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -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 Unit = {}, ) = PreferencesRootState( myUser = myUser, + preferencesAccountState = preferencesAccountState, version = version, isMultiAccountEnabled = isMultiAccountEnabled, theme = theme, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index b781b3e5c3..8ffb6bb307 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -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 = {}, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt index 9878612e29..a209097ad7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt @@ -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.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.setView( setContent { PreferencesAccountView( state = state, - onBackClick = onBackClick, - onAvatarClick = onAvatarClick, - onEditProfileClick = onEditProfileClick, onSecureBackupClick = onSecureBackupClick, onManageAccountClick = onManageAccountClick, onLinkNewDeviceClick = onLinkNewDeviceClick, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 7d8ad72cf9..3d438f7044 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -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, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 973aedc194..9c5b181833 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -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(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.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.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, ) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserHeaderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserHeaderView.kt deleted file mode 100644 index 482dd1aac6..0000000000 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserHeaderView.kt +++ /dev/null @@ -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 = {}, - ) -}