diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 3dd964610a..663b4e440f 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -40,8 +40,8 @@ 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.invite.api.InviteData +import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule @@ -514,7 +514,7 @@ private fun IncompleteContent( title = { when (roomIdOrAlias) { is RoomIdOrAlias.Alias -> { - RoomPreviewAliasAtom(roomIdOrAlias.identifier) + CopiableTextAtom(roomIdOrAlias.identifier) } is RoomIdOrAlias.Id -> { PlaceholderAtom(width = 200.dp, height = 22.dp) @@ -567,7 +567,7 @@ private fun DefaultLoadedContent( }, subtitle = { if (contentState.alias != null) { - RoomPreviewAliasAtom(contentState.alias.value) + CopiableTextAtom(contentState.alias.value) } if (contentState.details is LoadedDetails.Space) { Spacer(Modifier.height(8.dp)) 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 c85eed5b9d..f8268cb6d9 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,11 +26,14 @@ 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.advanced.AdvancedSettingsNode +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 import io.element.android.features.preferences.impl.labs.LabsNode +import io.element.android.features.preferences.impl.location.LocationSettingsNode +import io.element.android.features.preferences.impl.media.MediaSettingsNode +import io.element.android.features.preferences.impl.moderation.ModerationAndSafetyNode import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode import io.element.android.features.preferences.impl.root.PreferencesRootNode @@ -74,11 +77,17 @@ class PreferencesFlowNode( @Parcelize data object Root : NavTarget + @Parcelize + data object AccountSettings : NavTarget + @Parcelize data object DeveloperSettings : NavTarget @Parcelize - data object AdvancedSettings : NavTarget + data object MediaSettings : NavTarget + + @Parcelize + data object LocationSettings : NavTarget @Parcelize data object Labs : NavTarget @@ -92,6 +101,9 @@ class PreferencesFlowNode( @Parcelize data object NotificationSettings : NavTarget + @Parcelize + data object ModerationAndSafety : NavTarget + @Parcelize data object TroubleshootNotifications : NavTarget @@ -133,14 +145,6 @@ class PreferencesFlowNode( callback.navigateToAddAccount() } - override fun navigateToBugReport() { - callback.navigateToBugReport() - } - - override fun navigateToSecureBackup() { - callback.navigateToSecureBackup() - } - override fun navigateToAnalyticsSettings() { backstack.push(NavTarget.AnalyticsSettings) } @@ -153,24 +157,48 @@ class PreferencesFlowNode( backstack.push(NavTarget.DeveloperSettings) } - override fun navigateToNotificationSettings() { - backstack.push(NavTarget.NotificationSettings) - } - override fun navigateToLockScreenSettings() { backstack.push(NavTarget.LockScreenSettings) } - override fun navigateToAdvancedSettings() { - backstack.push(NavTarget.AdvancedSettings) + override fun navigateToMediaSettings() { + backstack.push(NavTarget.MediaSettings) + } + + override fun navigateToLocationSettings() { + backstack.push(NavTarget.LocationSettings) } override fun navigateToLabs() { 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() + } + + override fun navigateToSecureBackup() { + callback.navigateToSecureBackup() + } + + override fun navigateToModerationAndSafety() { + backstack.push(NavTarget.ModerationAndSafety) + } + + override fun navigateToNotificationSettings() { + backstack.push(NavTarget.NotificationSettings) + } + override fun navigateToLinkNewDevice() { - callback.navigateToLinkNewDevice() + callback.navigateToAddAccount() } override fun navigateToUserProfile(matrixUser: MatrixUser) { @@ -189,7 +217,7 @@ class PreferencesFlowNode( backstack.push(NavTarget.AccountDeactivation) } } - createNode(buildContext, plugins = listOf(callback)) + createNode(buildContext, plugins = listOf(callback)) } NavTarget.DeveloperSettings -> { val developerSettingsCallback = object : DeveloperSettingsNode.Callback { @@ -219,6 +247,12 @@ class PreferencesFlowNode( } createNode(buildContext, listOf(callback)) } + NavTarget.ModerationAndSafety -> { + createNode(buildContext) + } + NavTarget.LocationSettings -> { + createNode(buildContext) + } NavTarget.About -> { val callback = object : AboutNode.Callback { override fun navigateToOssLicenses() { @@ -309,8 +343,8 @@ class PreferencesFlowNode( val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) createNode(buildContext, plugins = listOf(input, callback)) } - NavTarget.AdvancedSettings -> { - createNode(buildContext) + NavTarget.MediaSettings -> { + createNode(buildContext) } is NavTarget.UserProfile -> { val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) 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 new file mode 100644 index 0000000000..08489b57bf --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountNode.kt @@ -0,0 +1,96 @@ +/* + * 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/PreferencesAccountPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt new file mode 100644 index 0000000000..e4c84538ce --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt @@ -0,0 +1,111 @@ +/* + * 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Inject +class PreferencesAccountPresenter( + private val matrixClient: MatrixClient, + private val sessionVerificationService: SessionVerificationService, + private val snackbarDispatcher: SnackbarDispatcher, + private val indicatorService: IndicatorService, + private val directLogoutPresenter: Presenter, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, + private val featureFlagService: FeatureFlagService, + private val sessionEnterpriseService: SessionEnterpriseService, +) : Presenter { + @Composable + override fun present(): PreferencesAccountState { + val matrixUser = matrixClient.userProfile.collectAsState() + LaunchedEffect(Unit) { + // Force a refresh of the profile + matrixClient.getUserProfile() + } + val showLinkNewDevice by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) + }.collectAsState(initial = false) + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + // We should display the 'complete verification' option if the current session can be verified + val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) + + val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() + + val accountManagementUrl: MutableState = remember { + mutableStateOf(null) + } + var canDeactivateAccount by remember { + mutableStateOf(false) + } + val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + LaunchedEffect(Unit) { + canDeactivateAccount = matrixClient.canDeactivateAccount() + } + + val nbOfBlockedUsers by produceState(initialValue = 0) { + matrixClient.ignoredUsersFlow + .onEach { value = it.size } + .launchIn(this) + } + + val directLogoutState = directLogoutPresenter.present() + + LaunchedEffect(Unit) { + initAccountManagementUrl(accountManagementUrl) + } + + return PreferencesAccountState( + myUser = matrixUser.value, + showSecureBackup = !canVerifyUserSession, + showSecureBackupBadge = showSecureBackupIndicator, + accountManagementUrl = accountManagementUrl.value, + canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, + canDeactivateAccount = canDeactivateAccount, + nbOfBlockedUsers = nbOfBlockedUsers, + directLogoutState = directLogoutState, + snackbarMessage = snackbarMessage, + ) + } + + private fun CoroutineScope.initAccountManagementUrl( + accountManagementUrl: MutableState, + ) = launch { + accountManagementUrl.value = matrixClient.getAccountManagementUrl(null) + .getOrNull() + ?.let { + sessionEnterpriseService.tweakMasUrl(it) + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt new file mode 100644 index 0000000000..a3f8dd3dc2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt @@ -0,0 +1,27 @@ +/* + * 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.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class PreferencesAccountState( + val myUser: MatrixUser, + val showSecureBackup: Boolean, + val showSecureBackupBadge: Boolean, + val accountManagementUrl: String?, + val canReportBug: Boolean, + val showLinkNewDevice: Boolean, + val canDeactivateAccount: Boolean, + val nbOfBlockedUsers: Int, + val directLogoutState: DirectLogoutState, + val snackbarMessage: SnackbarMessage?, +) { + val showBlockedUsersItem = nbOfBlockedUsers > 0 +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt new file mode 100644 index 0000000000..251e2dc22f --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt @@ -0,0 +1,75 @@ +/* + * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.ui.strings.CommonStrings + +open class PreferencesAccountStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Nominal state, that a regular user will see if multi account is enabled + aPreferencesAccountState( + myUser = aMatrixUser(avatarUrl = "anAvatarUrl"), + showSecureBackup = true, + accountManagementUrl = "aUrl", + canReportBug = true, + showLinkNewDevice = true, + canDeactivateAccount = false, + nbOfBlockedUsers = 3, + ), + aPreferencesAccountState( + myUser = aMatrixUser(displayName = null), + showSecureBackup = true, + canDeactivateAccount = true, + ), + aPreferencesAccountState( + accountManagementUrl = "aUrl", + showSecureBackup = true, + showSecureBackupBadge = true, + ), + aPreferencesAccountState( + canReportBug = true, + nbOfBlockedUsers = 3, + snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), + ), + aPreferencesAccountState( + showLinkNewDevice = true, + canDeactivateAccount = true, + ), + ) +} + +fun aPreferencesAccountState( + myUser: MatrixUser = aMatrixUser(), + showSecureBackup: Boolean = false, + showSecureBackupBadge: Boolean = false, + accountManagementUrl: String? = null, + canReportBug: Boolean = false, + showLinkNewDevice: Boolean = false, + canDeactivateAccount: Boolean = false, + nbOfBlockedUsers: Int = 0, + directLogoutState: DirectLogoutState = aDirectLogoutState(), + snackbarMessage: SnackbarMessage? = null, +) = PreferencesAccountState( + myUser = myUser, + showSecureBackup = showSecureBackup, + showSecureBackupBadge = showSecureBackupBadge, + accountManagementUrl = accountManagementUrl, + canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, + canDeactivateAccount = canDeactivateAccount, + nbOfBlockedUsers = nbOfBlockedUsers, + directLogoutState = directLogoutState, + snackbarMessage = snackbarMessage, +) 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 new file mode 100644 index 0000000000..e3ef579090 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt @@ -0,0 +1,224 @@ +/* + * 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 androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.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, + onOpenRageShake: () -> Unit, + onModerationAndSafetyClick: () -> Unit, + onOpenNotificationSettings: () -> Unit, + onOpenBlockedUsers: () -> Unit, + onSignOutClick: () -> Unit, + 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, + ) + } + } +} + +@Composable +private fun ManageAccountSection( + state: PreferencesAccountState, + onEditProfileClick: (MatrixUser) -> Unit, + onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, + onOpenBlockedUsers: () -> Unit, +) { + PreferenceCategory( + title = stringResource(CommonStrings.common_account_settings), + ) { + 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())), + onClick = { onManageAccountClick(url) }, + ) + } + if (state.showLinkNewDevice) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), + onClick = onLinkNewDeviceClick, + ) + } + if (state.showBlockedUsersItem) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), + onClick = onOpenBlockedUsers, + trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()), + ) + } + } +} + +@Composable +private fun ColumnScope.OtherSettingsSection( + state: PreferencesAccountState, + onOpenNotificationSettings: () -> Unit, + onSecureBackupClick: () -> Unit, +) { + HorizontalDivider() + ListItem( + content = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), + onClick = onOpenNotificationSettings, + ) + if (state.showSecureBackup) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_encryption)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())), + trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, + onClick = onSecureBackupClick, + ) + } +} + +@Composable +private fun ColumnScope.GeneralSection( + state: PreferencesAccountState, + onModerationAndSafetyClick: () -> Unit, + onOpenRageShake: () -> Unit, + onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, +) { + HorizontalDivider() + ListItem( + content = { Text(stringResource(id = CommonStrings.common_moderation_and_safety)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), + onClick = onModerationAndSafetyClick, + ) + + if (state.canReportBug) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), + onClick = onOpenRageShake, + ) + } + ListItem( + content = { Text(stringResource(id = CommonStrings.action_signout)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), + style = ListItemStyle.Destructive, + onClick = onSignOutClick, + ) + if (state.canDeactivateAccount) { + ListItem( + content = { Text(stringResource(id = CommonStrings.action_delete_account)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), + style = ListItemStyle.Destructive, + onClick = onDeactivateClick, + ) + } +} + +@PreviewWithLargeHeight +@Composable +internal fun PreferencesAccountViewLightPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) = + ElementPreviewLight( + drawableFallbackForImages = CommonDrawables.sample_avatar, + ) { ContentToPreview(state) } + +@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/advanced/AdvancedSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvent.kt deleted file mode 100644 index f2e6537d9d..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvent.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset - -sealed interface AdvancedSettingsEvent { - data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvent - data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvent - data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvent - data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvent - data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvent - data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvent - data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvent - data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvent - data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvent -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt deleted file mode 100644 index 3ebf1f5140..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import dev.zacsweers.metro.Inject -import io.element.android.compound.theme.Theme -import io.element.android.compound.theme.mapToTheme -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.api.store.SessionPreferencesStore -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -@Inject -class AdvancedSettingsPresenter( - private val appPreferencesStore: AppPreferencesStore, - private val sessionPreferencesStore: SessionPreferencesStore, - private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, - private val featureFlagService: FeatureFlagService, -) : Presenter { - @Composable - override fun present(): AdvancedSettingsState { - val isDeveloperModeEnabled by remember { - appPreferencesStore.isDeveloperModeEnabledFlow() - }.collectAsState(initial = false) - val isSharePresenceEnabled by remember { - sessionPreferencesStore.isSharePresenceEnabled() - }.collectAsState(initial = true) - val isBlackThemeAllowed by remember { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme) - }.collectAsState(initial = false) - val theme = remember(isBlackThemeAllowed) { - appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed) - }.collectAsState(initial = Theme.System) - - val liveLocationMinimumDistanceUpdate by produceState(null) { - appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it } - } - - val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() - - val themeOption by remember { - derivedStateOf { - when (theme.value) { - Theme.System -> ThemeOption.System - Theme.Dark -> ThemeOption.Dark - Theme.Black -> ThemeOption.Black - Theme.Light -> ThemeOption.Light - } - } - } - - val hasSplitMediaQualityOptions by produceState(null) { - value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) - } - - val availableThemeOptions = remember(isBlackThemeAllowed) { - if (isBlackThemeAllowed) { - ThemeOption.entries - } else { - ThemeOption.entries.filterNot { it == ThemeOption.Black } - }.toImmutableList() - } - - val mediaOptimizationState by produceState(null) { - val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality) - combine( - hasSplitMediaQualityOptionsFlow, - sessionPreferencesStore.doesOptimizeImages(), - sessionPreferencesStore.getVideoCompressionPreset() - ) { hasSplitOptions, compressImages, videoPreset -> - if (hasSplitMediaQualityOptions == true) { - value = MediaOptimizationState.Split( - compressImages = compressImages, - videoPreset = videoPreset, - ) - } else if (hasSplitMediaQualityOptions == false) { - value = MediaOptimizationState.AllMedia(isEnabled = compressImages) - } - }.collect() - } - - fun handleEvent(event: AdvancedSettingsEvent) { - when (event) { - is AdvancedSettingsEvent.SetDeveloperModeEnabled -> sessionCoroutineScope.launch { - appPreferencesStore.setDeveloperModeEnabled(event.enabled) - } - is AdvancedSettingsEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch { - sessionPreferencesStore.setSharePresence(event.enabled) - } - is AdvancedSettingsEvent.SetCompressMedia -> sessionCoroutineScope.launch { - sessionPreferencesStore.setOptimizeImages(event.compress) - } - is AdvancedSettingsEvent.SetTheme -> sessionCoroutineScope.launch { - when (event.theme) { - ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) - ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) - ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name) - ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) - } - } - is AdvancedSettingsEvent.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) - is AdvancedSettingsEvent.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) - is AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch { - appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value) - } - is AdvancedSettingsEvent.SetCompressImages -> sessionCoroutineScope.launch { - sessionPreferencesStore.setOptimizeImages(event.compress) - } - is AdvancedSettingsEvent.SetVideoUploadQuality -> sessionCoroutineScope.launch { - sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset) - } - } - } - - return AdvancedSettingsState( - isDeveloperModeEnabled = isDeveloperModeEnabled, - isSharePresenceEnabled = isSharePresenceEnabled, - mediaOptimizationState = mediaOptimizationState, - theme = themeOption, - availableThemeOptions = availableThemeOptions, - mediaPreviewConfigState = mediaPreviewConfigState, - liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, - eventSink = ::handleEvent, - ) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt deleted file mode 100644 index 689075759d..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -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.libraries.designsystem.components.preferences.DropdownOption -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import kotlinx.collections.immutable.ImmutableList - -data class AdvancedSettingsState( - val isDeveloperModeEnabled: Boolean, - val isSharePresenceEnabled: Boolean, - val mediaOptimizationState: MediaOptimizationState?, - val theme: ThemeOption, - val availableThemeOptions: ImmutableList, - val mediaPreviewConfigState: MediaPreviewConfigState, - val liveLocationMinimumDistanceUpdate: Int?, - val eventSink: (AdvancedSettingsEvent) -> Unit -) - -sealed interface MediaOptimizationState { - data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState - data class Split( - val compressImages: Boolean, - val videoPreset: VideoCompressionPreset, - ) : MediaOptimizationState - - val shouldCompressImages: Boolean get() = when (this) { - is AllMedia -> isEnabled - is Split -> compressImages - } -} - -enum class ThemeOption : DropdownOption { - System { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_system) - }, - - Light { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_light) - }, - - Dark { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_dark) - }, - - Black { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_black) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStatePreviewParam.kt deleted file mode 100644 index 3b6a323419..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStatePreviewParam.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -open class AdvancedSettingsStatePreviewParam : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aAdvancedSettingsState(), - aAdvancedSettingsState(isDeveloperModeEnabled = true), - aAdvancedSettingsState(isSharePresenceEnabled = true), - aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)), - aAdvancedSettingsState(hideInviteAvatars = true), - aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off), - aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading), - aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading), - aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split( - compressImages = true, - videoPreset = VideoCompressionPreset.HIGH, - )), - ) -} - -fun aAdvancedSettingsState( - isDeveloperModeEnabled: Boolean = false, - isSharePresenceEnabled: Boolean = false, - mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false), - theme: ThemeOption = ThemeOption.System, - availableThemeOptions: ImmutableList = ThemeOption.entries.toImmutableList(), - hideInviteAvatars: Boolean = false, - timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, - liveLocationMinimumDistanceUpdate: Int? = 50, - setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, - setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, - eventSink: (AdvancedSettingsEvent) -> Unit = {}, -) = AdvancedSettingsState( - isDeveloperModeEnabled = isDeveloperModeEnabled, - isSharePresenceEnabled = isSharePresenceEnabled, - mediaOptimizationState = mediaOptimizationState, - theme = theme, - availableThemeOptions = availableThemeOptions, - mediaPreviewConfigState = MediaPreviewConfigState( - hideInviteAvatars = hideInviteAvatars, - timelineMediaPreviewValue = timelineMediaPreviewValue, - setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, - setHideInviteAvatarsAction = setHideInviteAvatarsAction - ), - liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, - eventSink = eventSink -) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt deleted file mode 100644 index d7b1cfeb17..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ /dev/null @@ -1,446 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.SliderDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import im.vector.app.features.analytics.plan.Interaction -import io.element.android.compound.theme.ElementTheme -import io.element.android.features.preferences.impl.R -import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage -import io.element.android.libraries.designsystem.components.dialogs.ListDialog -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.PreferenceDropdown -import io.element.android.libraries.designsystem.components.preferences.PreferencePage -import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.ElementPreviewBlack -import io.element.android.libraries.designsystem.preview.ElementPreviewDark -import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.stringWithLink -import io.element.android.libraries.designsystem.theme.components.ListItem -import io.element.android.libraries.designsystem.theme.components.ListSectionHeader -import io.element.android.libraries.designsystem.theme.components.ListSupportingText -import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults -import io.element.android.libraries.designsystem.theme.components.Slider -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher -import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost -import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState -import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.ui.strings.CommonStrings -import io.element.android.services.analytics.compose.LocalAnalyticsService -import io.element.android.services.analyticsproviders.api.trackers.captureInteraction -import kotlin.math.roundToInt - -@Composable -fun AdvancedSettingsView( - state: AdvancedSettingsState, - onBackClick: () -> Unit, - onOpenAppSettingsClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val analyticsService = LocalAnalyticsService.current - - val snackbarDispatcher = LocalSnackbarDispatcher.current - val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) - - PreferencePage( - modifier = modifier, - onBackClick = onBackClick, - title = stringResource(id = CommonStrings.common_advanced_settings), - snackbarHost = { - SnackbarHost( - snackbarHostState, - modifier = Modifier.navigationBarsPadding() - ) - } - ) { - PreferenceDropdown( - title = stringResource(id = CommonStrings.common_appearance), - selectedOption = state.theme, - options = state.availableThemeOptions, - onSelectOption = { themeOption -> - state.eventSink(AdvancedSettingsEvent.SetTheme(themeOption)) - } - ) - ListItem( - content = { - Text(text = stringResource(id = CommonStrings.action_view_source)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description)) - }, - trailingContent = ListItemContent.Switch( - checked = state.isDeveloperModeEnabled, - ), - onClick = { state.eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) } - ) - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description)) - }, - trailingContent = ListItemContent.Switch( - checked = state.isSharePresenceEnabled, - ), - onClick = { state.eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } - ) - val compressImages = state.mediaOptimizationState?.shouldCompressImages - - when (state.mediaOptimizationState) { - null -> Unit - is MediaOptimizationState.AllMedia -> { - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description)) - }, - trailingContent = ListItemContent.Switch( - checked = compressImages ?: false, - ), - onClick = { - val newValue = !(compressImages ?: false) - analyticsService.captureInteraction( - if (newValue) { - Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled - } else { - Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled - } - ) - state.eventSink(AdvancedSettingsEvent.SetCompressMedia(newValue)) - } - ) - } - is MediaOptimizationState.Split -> { - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description)) - }, - trailingContent = ListItemContent.Switch( - checked = compressImages ?: false, - ), - onClick = { - val newValue = !(compressImages ?: false) - analyticsService.captureInteraction( - if (newValue) { - Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled - } else { - Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled - } - ) - state.eventSink(AdvancedSettingsEvent.SetCompressMedia(newValue)) - } - ) - - var displaySelectorDialog by remember { mutableStateOf(false) } - - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title)) - }, - supportingContent = { - val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description) - val quality = when (state.mediaOptimizationState.videoPreset) { - VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low) - VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard) - VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high) - } - val descriptionWithValue = remember(quality) { - String.format(description, quality) - } - Text(text = descriptionWithValue) - }, - onClick = { displaySelectorDialog = true }, - ) - - if (displaySelectorDialog) { - VideoQualitySelectorDialog( - selectedPreset = state.mediaOptimizationState.videoPreset, - onSubmit = { preset -> - state.eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(preset)) - displaySelectorDialog = false - }, - onDismiss = { displaySelectorDialog = false }, - ) - } - } - } - - ModerationAndSafety(state) - if (state.liveLocationMinimumDistanceUpdate != null) { - LiveLocationUpdatesSection( - value = state.liveLocationMinimumDistanceUpdate, - onSaveValue = { value -> - state.eventSink(AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate(value)) - }, - onOpenAppPermissionsClick = onOpenAppSettingsClick, - ) - } - } -} - -@Composable -private fun VideoQualitySelectorDialog( - selectedPreset: VideoCompressionPreset, - onSubmit: (VideoCompressionPreset) -> Unit, - onDismiss: () -> Unit -) { - val videoPresets = VideoCompressionPreset.entries - var localSelectedPreset by remember { mutableStateOf(selectedPreset) } - ListDialog( - title = stringResource(CommonStrings.dialog_video_quality_selector_title), - subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle), - onSubmit = { onSubmit(localSelectedPreset) }, - onDismissRequest = onDismiss, - applyPaddingToContents = false, - ) { - for (preset in videoPresets) { - val isSelected = preset == localSelectedPreset - item( - key = preset, - contentType = preset, - ) { - val title = when (preset) { - VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low) - VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard) - VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high) - } - val subtitle = when (preset) { - VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description) - VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description) - VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description) - } - ListItem( - content = { - Text( - text = title, - style = ElementTheme.typography.fontBodyLgMedium, - ) - }, - supportingContent = { - Text( - text = subtitle, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - }, - leadingContent = ListItemContent.RadioButton( - selected = isSelected, - ), - onClick = { - localSelectedPreset = preset - }, - ) - } - } - } -} - -@Composable -private fun ModerationAndSafety( - state: AdvancedSettingsState, - modifier: Modifier = Modifier, -) { - PreferenceCategory( - modifier = modifier, - title = stringResource(R.string.screen_advanced_settings_moderation_and_safety_section_title), - showTopDivider = true - ) { - PreferenceSwitch( - title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), - isChecked = state.mediaPreviewConfigState.hideInviteAvatars, - onCheckedChange = { - state.eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(it)) - }, - enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() - ) - ListSectionHeader( - title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), - hasDivider = false, - description = { - ListSupportingText( - text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), - contentPadding = ListSupportingTextDefaults.Padding.None, - ) - } - ) - ListItem( - content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, - leadingContent = ListItemContent.RadioButton( - selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, - compact = true - ), - onClick = { - state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - }, - enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() - ) - ListItem( - content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, - leadingContent = ListItemContent.RadioButton( - selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, - compact = true - ), - onClick = { - state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - }, - enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() - ) - ListItem( - content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, - leadingContent = ListItemContent.RadioButton( - selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, - compact = true - ), - onClick = { - state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) - }, - enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() - ) - } -} - -@Composable -private fun LiveLocationUpdatesSection( - value: Int, - onSaveValue: (Int) -> Unit, - onOpenAppPermissionsClick: () -> Unit, - modifier: Modifier = Modifier, -) { - PreferenceCategory( - modifier = modifier, - showTopDivider = true, - ) { - ListSectionHeader( - title = stringResource(R.string.screen_advanced_settings_live_location_section_title), - description = { - ListSupportingText( - text = stringResource(R.string.screen_advanced_settings_live_location_section_description), - contentPadding = ListSupportingTextDefaults.Padding.None, - ) - } - ) - var sliderValue by remember(value) { mutableIntStateOf(value) } - Column( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = pluralStringResource( - R.plurals.screen_advanced_settings_live_location_update_distance, - sliderValue, - sliderValue, - ), - style = ElementTheme.typography.fontBodyLgRegular, - color = ElementTheme.colors.textPrimary, - ) - val valueRange = 1f..100f - val start = valueRange.start.toInt() - val end = valueRange.endInclusive.toInt() - Row(verticalAlignment = Alignment.CenterVertically) { - Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) - Slider( - modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp), - value = sliderValue.toFloat(), - onValueChange = { sliderValue = it.roundToInt() }, - onValueChangeFinish = { - onSaveValue(sliderValue) - }, - valueRange = valueRange, - colors = SliderDefaults.colors( - thumbColor = ElementTheme.colors.iconAccentPrimary, - activeTrackColor = ElementTheme.colors.iconAccentPrimary, - inactiveTrackColor = ElementTheme.colors.bgBadgeAccent, - inactiveTickColor = ElementTheme.colors.iconAccentPrimary, - ) - ) - Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) - } - } - val footerText = stringWithLink( - textRes = R.string.screen_advanced_settings_live_location_section_footer, - url = "", - linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link, - onLinkClick = { onOpenAppPermissionsClick() }, - ) - ListSupportingText( - annotatedString = footerText, - contentPadding = ListSupportingTextDefaults.Padding.Default, - ) - } -} - -@PreviewWithLargeHeight -@Composable -internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) = - ElementPreviewLight { ContentToPreview(state) } - -@PreviewWithLargeHeight -@Composable -internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) = - ElementPreviewDark { ContentToPreview(state) } - -@PreviewWithLargeHeight -@Composable -internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) = - ElementPreviewBlack { ContentToPreview(state) } - -@ExcludeFromCoverage -@Composable -private fun ContentToPreview(state: AdvancedSettingsState) { - AdvancedSettingsView( - state = state, - onBackClick = { }, - onOpenAppSettingsClick = {} - ) -} - -@Composable -@PreviewsDayNight -internal fun VideoQualitySelectorDialogPreview() { - ElementPreview { - VideoQualitySelectorDialog( - selectedPreset = VideoCompressionPreset.STANDARD, - onSubmit = { /* no-op */ }, - onDismiss = { /* no-op */ } - ) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt index d9641a2810..9f328fdd9b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.tracing.TraceLogPack sealed interface AppDeveloperSettingsEvent { + data class SetDeveloperModeEnabled(val enabled: Boolean) : AppDeveloperSettingsEvent data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : AppDeveloperSettingsEvent data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt index 9c1127dd2a..1bf162d3ea 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt @@ -47,6 +47,9 @@ class AppDeveloperSettingsPresenter( ) : Presenter { @Composable override fun present(): AppDeveloperSettingsState { + val isDeveloperModeEnabled by remember { + appPreferencesStore.isDeveloperModeEnabledFlow() + }.collectAsState(initial = false) val rageshakeState = rageshakePresenter.present() val enabledFeatures = remember { mutableStateListOf() @@ -79,6 +82,9 @@ class AppDeveloperSettingsPresenter( fun handleEvent(event: AppDeveloperSettingsEvent) { when (event) { + is AppDeveloperSettingsEvent.SetDeveloperModeEnabled -> coroutineScope.launch { + appPreferencesStore.setDeveloperModeEnabled(event.enabled) + } is AppDeveloperSettingsEvent.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( enabledFeatures = enabledFeatures, featureKey = event.feature.key, @@ -104,6 +110,7 @@ class AppDeveloperSettingsPresenter( } return AppDeveloperSettingsState( + isDeveloperModeEnabled = isDeveloperModeEnabled, features = featureUiModels, rageshakeState = rageshakeState, customElementCallBaseUrlState = CustomElementCallBaseUrlState( diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt index 071670644c..07067da9b5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.tracing.TraceLogPack import kotlinx.collections.immutable.ImmutableList data class AppDeveloperSettingsState( + val isDeveloperModeEnabled: Boolean, val features: ImmutableList, val rageshakeState: RageshakePreferencesState, val customElementCallBaseUrlState: CustomElementCallBaseUrlState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt index 480b8426d7..272ecb6a91 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt @@ -24,14 +24,19 @@ open class AppDeveloperSettingsStatePreviewParam : PreviewParameterProvider = emptyList(), eventSink: (AppDeveloperSettingsEvent) -> Unit = {}, ) = AppDeveloperSettingsState( + isDeveloperModeEnabled = isDeveloperModeEnabled, features = aFeatureUiModelList(), rageshakeState = aRageshakePreferencesState(), customElementCallBaseUrlState = customElementCallBaseUrlState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt index 1285269ca9..7f10745872 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -24,6 +24,7 @@ import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView import io.element.android.libraries.androidutils.system.copyToClipboard +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.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch @@ -48,6 +49,18 @@ fun AppDeveloperSettingsView( modifier = modifier.fillMaxWidth(), ) { // Note: this is OK to hardcode strings in this debug screen. + ListItem( + content = { + Text(text = stringResource(id = CommonStrings.action_view_source)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isDeveloperModeEnabled, + ), + onClick = { state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) } + ) PreferenceCategory( title = "Feature flags", showTopDivider = false, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsEvent.kt new file mode 100644 index 0000000000..8461621637 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsEvent.kt @@ -0,0 +1,12 @@ +/* + * 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.location + +sealed interface LocationSettingsEvent { + data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : LocationSettingsEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsNode.kt similarity index 81% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsNode.kt index 96b1cddc94..629dfeb3d2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsNode.kt @@ -1,12 +1,11 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. + * 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.advanced +package io.element.android.features.preferences.impl.location import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -22,20 +21,20 @@ import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @AssistedInject -class AdvancedSettingsNode( +class LocationSettingsNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: AdvancedSettingsPresenter, + private val presenter: LocationSettingsPresenter, ) : Node(buildContext, plugins = plugins) { @Composable override fun View(modifier: Modifier) { val state = presenter.present() val context = LocalContext.current - AdvancedSettingsView( + LocationSettingsView( state = state, modifier = modifier, onBackClick = ::navigateUp, - onOpenAppSettingsClick = context::openAppSettingsPage + onOpenAppSettingsClick = context::openAppSettingsPage, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenter.kt new file mode 100644 index 0000000000..9461b82dad --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenter.kt @@ -0,0 +1,45 @@ +/* + * 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.location + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class LocationSettingsPresenter( + private val appPreferencesStore: AppPreferencesStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): LocationSettingsState { + val liveLocationMinimumDistanceUpdate by produceState(10) { + appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it } + } + + fun handleEvent(event: LocationSettingsEvent) { + when (event) { + is LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch { + appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value) + } + } + } + + return LocationSettingsState( + liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsState.kt new file mode 100644 index 0000000000..1328b7c194 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsState.kt @@ -0,0 +1,13 @@ +/* + * 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.location + +data class LocationSettingsState( + val liveLocationMinimumDistanceUpdate: Int, + val eventSink: (LocationSettingsEvent) -> Unit +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsStatePreviewParam.kt new file mode 100644 index 0000000000..cede867a1e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsStatePreviewParam.kt @@ -0,0 +1,26 @@ +/* + * 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.location + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class LocationSettingsStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLocationSettingsState(), + aLocationSettingsState(liveLocationMinimumDistanceUpdate = 80), + ) +} + +fun aLocationSettingsState( + liveLocationMinimumDistanceUpdate: Int = 50, + eventSink: (LocationSettingsEvent) -> Unit = {}, +) = LocationSettingsState( + liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsView.kt new file mode 100644 index 0000000000..9fb6719978 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsView.kt @@ -0,0 +1,152 @@ +/* + * 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.location + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.text.stringWithLink +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults +import io.element.android.libraries.designsystem.theme.components.Slider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.math.roundToInt + +@Composable +fun LocationSettingsView( + state: LocationSettingsState, + onBackClick: () -> Unit, + onOpenAppSettingsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_location_sharing), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + LiveLocationUpdatesSection( + value = state.liveLocationMinimumDistanceUpdate, + onSaveValue = { value -> + state.eventSink(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(value)) + }, + onOpenAppPermissionsClick = onOpenAppSettingsClick, + ) + } +} + +@Composable +private fun ColumnScope.LiveLocationUpdatesSection( + value: Int, + onSaveValue: (Int) -> Unit, + onOpenAppPermissionsClick: () -> Unit, +) { + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_live_location_section_title), + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_live_location_section_description), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + }, + hasDivider = false, + ) + var sliderValue by remember(value) { mutableIntStateOf(value) } + Column( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = pluralStringResource( + R.plurals.screen_advanced_settings_live_location_update_distance, + sliderValue, + sliderValue, + ), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + val valueRange = 1f..100f + val start = valueRange.start.toInt() + val end = valueRange.endInclusive.toInt() + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) + Slider( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + value = sliderValue.toFloat(), + onValueChange = { sliderValue = it.roundToInt() }, + onValueChangeFinish = { + onSaveValue(sliderValue) + }, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = ElementTheme.colors.iconAccentPrimary, + activeTrackColor = ElementTheme.colors.iconAccentPrimary, + inactiveTrackColor = ElementTheme.colors.bgBadgeAccent, + inactiveTickColor = ElementTheme.colors.iconAccentPrimary, + ) + ) + Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) + } + } + val footerText = stringWithLink( + textRes = R.string.screen_advanced_settings_live_location_section_footer, + url = "", + linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link, + onLinkClick = { onOpenAppPermissionsClick() }, + ) + ListSupportingText( + annotatedString = footerText, + contentPadding = ListSupportingTextDefaults.Padding.Default, + ) +} + +@Preview +@Composable +internal fun LocationSettingsViewPreview(@PreviewParameter(LocationSettingsStatePreviewParam::class) state: LocationSettingsState) { + LocationSettingsView( + state = state, + onBackClick = { }, + onOpenAppSettingsClick = {} + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsEvent.kt new file mode 100644 index 0000000000..7f8af796ad --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +sealed interface MediaSettingsEvent { + data class SetCompressMedia(val compress: Boolean) : MediaSettingsEvent + data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : MediaSettingsEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsNode.kt new file mode 100644 index 0000000000..826341f0f0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +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.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class MediaSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: MediaSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MediaSettingsView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenter.kt new file mode 100644 index 0000000000..35a1ed63f9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +@Inject +class MediaSettingsPresenter( + private val sessionPreferencesStore: SessionPreferencesStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @Composable + override fun present(): MediaSettingsState { + val mediaOptimizationState by produceState(null) { + val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality) + combine( + hasSplitMediaQualityOptionsFlow, + sessionPreferencesStore.doesOptimizeImages(), + sessionPreferencesStore.getVideoCompressionPreset() + ) { hasSplitOptions, compressImages, videoPreset -> + if (hasSplitOptions) { + MediaOptimizationState.Split( + compressImages = compressImages, + videoPreset = videoPreset, + ) + } else { + MediaOptimizationState.AllMedia(isEnabled = compressImages) + } + }.collect { value = it } + } + + fun handleEvent(event: MediaSettingsEvent) { + when (event) { + is MediaSettingsEvent.SetCompressMedia -> sessionCoroutineScope.launch { + sessionPreferencesStore.setOptimizeImages(event.compress) + } + is MediaSettingsEvent.SetVideoUploadQuality -> sessionCoroutineScope.launch { + sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset) + } + } + } + + return MediaSettingsState( + mediaOptimizationState = mediaOptimizationState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsState.kt new file mode 100644 index 0000000000..d6796e9719 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +data class MediaSettingsState( + val mediaOptimizationState: MediaOptimizationState?, + val eventSink: (MediaSettingsEvent) -> Unit +) + +sealed interface MediaOptimizationState { + data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState + data class Split( + val compressImages: Boolean, + val videoPreset: VideoCompressionPreset, + ) : MediaOptimizationState + + val shouldCompressImages: Boolean + get() = when (this) { + is AllMedia -> isEnabled + is Split -> compressImages + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsStatePreviewParam.kt new file mode 100644 index 0000000000..d12743e4aa --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsStatePreviewParam.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +open class MediaSettingsStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaSettingsState(), + aMediaSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)), + aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.HIGH, + ) + ), + ) +} + +fun aMediaSettingsState( + mediaOptimizationState: MediaOptimizationState? = MediaOptimizationState.AllMedia(isEnabled = false), + eventSink: (MediaSettingsEvent) -> Unit = {}, +) = MediaSettingsState( + mediaOptimizationState = mediaOptimizationState, + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsView.kt new file mode 100644 index 0000000000..5fa1cbff1a --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsView.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction + +@Composable +fun MediaSettingsView( + state: MediaSettingsState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val analyticsService = LocalAnalyticsService.current + + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_media_upload_quality), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + val compressImages = state.mediaOptimizationState?.shouldCompressImages + + when (state.mediaOptimizationState) { + null -> Unit + is MediaOptimizationState.AllMedia -> { + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(MediaSettingsEvent.SetCompressMedia(newValue)) + } + ) + } + is MediaOptimizationState.Split -> { + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(MediaSettingsEvent.SetCompressMedia(newValue)) + } + ) + + var displaySelectorDialog by remember { mutableStateOf(false) } + + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title)) + }, + supportingContent = { + val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description) + val quality = when (state.mediaOptimizationState.videoPreset) { + VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val descriptionWithValue = remember(quality) { + String.format(description, quality) + } + Text(text = descriptionWithValue) + }, + onClick = { displaySelectorDialog = true }, + ) + + if (displaySelectorDialog) { + VideoQualitySelectorDialog( + selectedPreset = state.mediaOptimizationState.videoPreset, + onSubmit = { preset -> + state.eventSink(MediaSettingsEvent.SetVideoUploadQuality(preset)) + displaySelectorDialog = false + }, + onDismiss = { displaySelectorDialog = false }, + ) + } + } + } + } +} + +@Composable +private fun VideoQualitySelectorDialog( + selectedPreset: VideoCompressionPreset, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit +) { + val videoPresets = VideoCompressionPreset.entries + var localSelectedPreset by remember { mutableStateOf(selectedPreset) } + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle), + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (preset in videoPresets) { + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val title = when (preset) { + VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val subtitle = when (preset) { + VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description) + VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description) + VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description) + } + ListItem( + content = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = subtitle, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton( + selected = isSelected, + ), + onClick = { + localSelectedPreset = preset + }, + ) + } + } + } +} + +@Composable +@PreviewsDayNight +internal fun MediaSettingsViewPreview( + @PreviewParameter(MediaSettingsStatePreviewParam::class) state: MediaSettingsState, +) { + MediaSettingsView( + state = state, + onBackClick = { }, + ) +} + +@Composable +@PreviewsDayNight +internal fun VideoQualitySelectorDialogPreview() { + ElementPreview { + VideoQualitySelectorDialog( + selectedPreset = VideoCompressionPreset.STANDARD, + onSubmit = { /* no-op */ }, + onDismiss = { /* no-op */ } + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStore.kt similarity index 98% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStore.kt index d23168e29a..e13bbf84f8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStore.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.moderation import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyEvent.kt new file mode 100644 index 0000000000..8bcc07ad9a --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyEvent.kt @@ -0,0 +1,16 @@ +/* + * 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.moderation + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue + +sealed interface ModerationAndSafetyEvent { + data class SetSharePresenceEnabled(val enabled: Boolean) : ModerationAndSafetyEvent + data class SetHideInviteAvatars(val value: Boolean) : ModerationAndSafetyEvent + data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : ModerationAndSafetyEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyNode.kt new file mode 100644 index 0000000000..2c835e9a9c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyNode.kt @@ -0,0 +1,36 @@ +/* + * 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.moderation + +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.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ModerationAndSafetyNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ModerationAndSafetyPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ModerationAndSafetyView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenter.kt new file mode 100644 index 0000000000..0ce558aca9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenter.kt @@ -0,0 +1,52 @@ +/* + * 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.moderation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class ModerationAndSafetyPresenter( + private val sessionPreferencesStore: SessionPreferencesStore, + private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): ModerationAndSafetyState { + val isSharePresenceEnabled by remember { + sessionPreferencesStore.isSharePresenceEnabled() + }.collectAsState(initial = true) + + val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() + + fun handleEvent(event: ModerationAndSafetyEvent) { + when (event) { + is ModerationAndSafetyEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch { + sessionPreferencesStore.setSharePresence(event.enabled) + } + is ModerationAndSafetyEvent.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) + is ModerationAndSafetyEvent.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) + } + } + + return ModerationAndSafetyState( + isSharePresenceEnabled = isSharePresenceEnabled, + mediaPreviewConfigState = mediaPreviewConfigState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyState.kt new file mode 100644 index 0000000000..ac08054c77 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyState.kt @@ -0,0 +1,14 @@ +/* + * 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.moderation + +data class ModerationAndSafetyState( + val isSharePresenceEnabled: Boolean, + val mediaPreviewConfigState: MediaPreviewConfigState, + val eventSink: (ModerationAndSafetyEvent) -> Unit +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyStatePreviewParam.kt new file mode 100644 index 0000000000..3562bcfba2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyStatePreviewParam.kt @@ -0,0 +1,42 @@ +/* + * 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.moderation + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue + +open class ModerationAndSafetyStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aModerationAndSafetyState(), + aModerationAndSafetyState(isSharePresenceEnabled = true), + aModerationAndSafetyState(hideInviteAvatars = true), + aModerationAndSafetyState(timelineMediaPreviewValue = MediaPreviewValue.Off), + aModerationAndSafetyState(setHideInviteAvatarsAction = AsyncAction.Loading), + aModerationAndSafetyState(setTimelineMediaPreviewAction = AsyncAction.Loading), + ) +} + +fun aModerationAndSafetyState( + isSharePresenceEnabled: Boolean = false, + hideInviteAvatars: Boolean = false, + timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, + setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, + setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ModerationAndSafetyEvent) -> Unit = {}, +) = ModerationAndSafetyState( + isSharePresenceEnabled = isSharePresenceEnabled, + mediaPreviewConfigState = MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, + setHideInviteAvatarsAction = setHideInviteAvatarsAction + ), + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyView.kt new file mode 100644 index 0000000000..24c5598149 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyView.kt @@ -0,0 +1,180 @@ +/* + * 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.moderation + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +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.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreviewBlack +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.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=606-56426 + */ +@Composable +fun ModerationAndSafetyView( + state: ModerationAndSafetyState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_moderation_and_safety), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + PresenceSection(state) + InvitesSection(state) + MediaSection(state) + } +} + +@Composable +private fun PresenceSection(state: ModerationAndSafetyState) { + PreferenceCategory( + title = stringResource(R.string.screen_moderation_and_safety_share_presence_heading), + ) { + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isSharePresenceEnabled, + ), + onClick = { state.eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } + ) + } +} + +@Composable +private fun InvitesSection( + state: ModerationAndSafetyState, + modifier: Modifier = Modifier, +) { + PreferenceCategory( + modifier = modifier, + title = stringResource(R.string.screen_moderation_and_safety_invites_heading), + ) { + PreferenceSwitch( + title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), + isChecked = state.mediaPreviewConfigState.hideInviteAvatars, + onCheckedChange = { + state.eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(it)) + }, + enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() + ) + } +} + +@Composable +private fun ColumnScope.MediaSection( + state: ModerationAndSafetyState, +) { + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), + hasDivider = true, + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } + ) + ListItem( + content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, + trailingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, + compact = true + ), + onClick = { + state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + ListItem( + content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, + trailingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, + compact = true + ), + onClick = { + state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + ListItem( + content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, + trailingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, + compact = true + ), + onClick = { + state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) +} + +@PreviewWithLargeHeight +@Composable +internal fun ModerationAndSafetyViewLightPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun ModerationAndSafetyViewDarkPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) = + ElementPreviewDark { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun ModerationAndSafetyViewBlackPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) = + ElementPreviewBlack { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: ModerationAndSafetyState) { + ModerationAndSafetyView( + state = state, + onBackClick = { } + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index db15f8a0b2..be2c873a3b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -14,4 +14,5 @@ sealed interface PreferencesRootEvent { data object OnVersionInfoClick : PreferencesRootEvent data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent data object ToggleOtherAccountsExpanded : PreferencesRootEvent + data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent } 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 4fdac07550..6fdb568156 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,8 +8,6 @@ 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 @@ -18,14 +16,9 @@ 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.emoji.api.picker.EmojiPickerRenderer -import io.element.android.libraries.matrix.api.user.MatrixUser @ContributesNode(SessionScope::class) @AssistedInject @@ -33,77 +26,39 @@ class PreferencesRootNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: PreferencesRootPresenter, - private val directLogoutView: DirectLogoutView, private val emojiPickerRenderer: EmojiPickerRenderer, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun navigateToAddAccount() - fun navigateToBugReport() - fun navigateToSecureBackup() fun navigateToAnalyticsSettings() fun navigateToAbout() fun navigateToDeveloperSettings() - fun navigateToNotificationSettings() fun navigateToLockScreenSettings() - fun navigateToAdvancedSettings() + fun navigateToMediaSettings() + fun navigateToLocationSettings() fun navigateToLabs() - fun navigateToLinkNewDevice() - fun navigateToUserProfile(matrixUser: MatrixUser) - fun navigateToBlockedUsers() - fun startSignOutFlow() - fun startAccountDeactivationFlow() + 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, modifier = modifier, onBackClick = this::navigateUp, onAddAccountClick = callback::navigateToAddAccount, - onOpenRageShake = callback::navigateToBugReport, onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAbout = callback::navigateToAbout, - onSecureBackupClick = callback::navigateToSecureBackup, onOpenDeveloperSettings = callback::navigateToDeveloperSettings, - onOpenAdvancedSettings = callback::navigateToAdvancedSettings, + onOpenMediaSettings = callback::navigateToMediaSettings, + onOpenLocationSettings = callback::navigateToLocationSettings, onOpenLabs = callback::navigateToLabs, - onLinkNewDeviceClick = callback::navigateToLinkNewDevice, - onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, - onOpenNotificationSettings = callback::navigateToNotificationSettings, onOpenLockScreenSettings = callback::navigateToLockScreenSettings, - onOpenUserProfile = callback::navigateToUserProfile, - onOpenBlockedUsers = callback::navigateToBlockedUsers, - onSignOutClick = { - if (state.directLogoutState.canDoDirectSignOut) { - state.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) - } else { - callback.startSignOutFlow() - } - }, - onDeactivateClick = callback::startAccountDeactivationFlow + onOpenAccountSettings = callback::navigateToAccountSettings, ) - - directLogoutView.Render(state = state.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 bb6a964b88..c72def17a6 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 @@ -10,57 +10,48 @@ package io.element.android.features.preferences.impl.root import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.compound.theme.Theme +import io.element.android.compound.theme.mapToTheme import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider -import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @Inject class PreferencesRootPresenter( private val matrixClient: MatrixClient, - private val sessionVerificationService: SessionVerificationService, private val analyticsService: AnalyticsService, private val versionFormatter: VersionFormatter, private val snackbarDispatcher: SnackbarDispatcher, - private val indicatorService: IndicatorService, - private val directLogoutPresenter: Presenter, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, - private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, private val appPreferencesStore: AppPreferencesStore, - private val sessionEnterpriseService: SessionEnterpriseService, private val userStatusPresenter: Presenter, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, ) : Presenter { @Composable override fun present(): PreferencesRootState { @@ -76,14 +67,16 @@ class PreferencesRootPresenter( val isOtherAccountsSectionExpanded by remember { appPreferencesStore.isOtherAccountsExpandedFlow() }.collectAsState(initial = true) - val showLinkNewDevice by remember { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) - }.collectAsState(initial = false) - val isUserStatusSupported by produceState(false) { value = matrixClient.isUserStatusSupported().getOrDefault(false) } val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null + val isBlackThemeAllowed by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme) + }.collectAsState(initial = false) + val theme = remember(isBlackThemeAllowed) { + appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed) + }.collectAsState(initial = Theme.System) val otherSessions by remember { sessionStore.sessionsFlow().map { list -> @@ -100,39 +93,30 @@ class PreferencesRootPresenter( } }.collectAsState(initial = persistentListOf()) + val themeOption by remember { + derivedStateOf { + when (theme.value) { + Theme.System -> ThemeOption.System + Theme.Dark -> ThemeOption.Dark + Theme.Black -> ThemeOption.Black + Theme.Light -> ThemeOption.Light + } + } + } + + val availableThemeOptions = remember(isBlackThemeAllowed) { + if (isBlackThemeAllowed) { + ThemeOption.entries + } else { + ThemeOption.entries.filterNot { it == ThemeOption.Black } + }.toImmutableList() + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() } - // We should display the 'complete verification' option if the current session can be verified - val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) - - val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() - - val accountManagementUrl: MutableState = remember { - mutableStateOf(null) - } - var canDeactivateAccount by remember { - mutableStateOf(false) - } - val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) - LaunchedEffect(Unit) { - canDeactivateAccount = matrixClient.canDeactivateAccount() - } - - val nbOfBlockedUsers by produceState(initialValue = 0) { - matrixClient.ignoredUsersFlow - .onEach { value = it.size } - .launchIn(this) - } - val showLabsItem = remember { featureFlagService.getAvailableFeatures(isInLabs = true).isNotEmpty() } - val directLogoutState = directLogoutPresenter.present() - - LaunchedEffect(Unit) { - initAccountManagementUrl(accountManagementUrl) - } - val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() fun handleEvent(event: PreferencesRootEvent) { @@ -146,39 +130,31 @@ class PreferencesRootPresenter( PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch { appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded) } + is PreferencesRootEvent.SetTheme -> sessionCoroutineScope.launch { + when (event.theme) { + ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) + ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) + ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name) + ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) + } + } } } return PreferencesRootState( myUser = matrixUser.value, userStatusState = userStatusState, + theme = themeOption, + availableThemeOptions = availableThemeOptions, version = remember { versionFormatter.get() }, isMultiAccountEnabled = isMultiAccountEnabled, isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded, otherSessions = otherSessions, - showSecureBackup = !canVerifyUserSession, - showSecureBackupBadge = showSecureBackupIndicator, - accountManagementUrl = accountManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, - canReportBug = canReportBug, - showLinkNewDevice = showLinkNewDevice, showDeveloperSettings = showDeveloperSettings, - canDeactivateAccount = canDeactivateAccount, - nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, - directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, eventSink = ::handleEvent, ) } - - private fun CoroutineScope.initAccountManagementUrl( - accountManagementUrl: MutableState, - ) = launch { - accountManagementUrl.value = matrixClient.getAccountManagementUrl(null) - .getOrNull() - ?.let { - sessionEnterpriseService.tweakMasUrl(it) - } - } } 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 3cc5f64665..195d7589f7 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 @@ -8,32 +8,54 @@ package io.element.android.features.preferences.impl.root -import io.element.android.features.logout.api.direct.DirectLogoutState +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.userstatus.UserStatusState +import io.element.android.libraries.designsystem.components.preferences.DropdownOption import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, + val theme: ThemeOption, + val availableThemeOptions: ImmutableList, val userStatusState: UserStatusState?, val version: String, val isMultiAccountEnabled: Boolean, val isOtherAccountsSectionExpanded: Boolean, val otherSessions: ImmutableList, - val showSecureBackup: Boolean, - val showSecureBackupBadge: Boolean, - val accountManagementUrl: String?, - val canReportBug: Boolean, - val showLinkNewDevice: Boolean, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, - val canDeactivateAccount: Boolean, - val nbOfBlockedUsers: Int, val showLabsItem: Boolean, - val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, val eventSink: (PreferencesRootEvent) -> Unit, -) { - val showBlockedUsersItem = nbOfBlockedUsers > 0 +) + +enum class ThemeOption : DropdownOption { + System { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_system) + }, + + Light { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_light) + }, + + Dark { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_dark) + }, + + Black { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_black) + } } 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 280a9c1eca..5ce7fafa00 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,8 +9,6 @@ package io.element.android.features.preferences.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.aUserStatusState @@ -30,40 +28,25 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider = ThemeOption.entries, otherSessions: List = emptyList(), - showSecureBackup: Boolean = false, - showSecureBackupBadge: Boolean = false, - accountManagementUrl: String? = null, - canReportBug: Boolean = false, - showLinkNewDevice: Boolean = false, showAnalyticsSettings: Boolean = false, showDeveloperSettings: Boolean = false, - canDeactivateAccount: Boolean = false, - nbOfBlockedUsers: Int = 0, showLabsItem: Boolean = false, - directLogoutState: DirectLogoutState = aDirectLogoutState(), userStatusState: UserStatusState? = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, @@ -95,19 +72,13 @@ fun aPreferencesRootState( myUser = myUser, version = version, isMultiAccountEnabled = isMultiAccountEnabled, + theme = theme, + availableThemeOptions = availableThemeOptions.toImmutableList(), isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded, otherSessions = otherSessions.toImmutableList(), - showSecureBackup = showSecureBackup, - showSecureBackupBadge = showSecureBackupBadge, - accountManagementUrl = accountManagementUrl, - canReportBug = canReportBug, - showLinkNewDevice = showLinkNewDevice, showAnalyticsSettings = showAnalyticsSettings, showDeveloperSettings = showDeveloperSettings, - canDeactivateAccount = canDeactivateAccount, - nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, - directLogoutState = directLogoutState, userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, 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 5aefdf122d..e10736dbab 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 @@ -48,6 +48,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType 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.PreferenceDropdown 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 @@ -56,14 +58,12 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi import io.element.android.libraries.designsystem.theme.components.Icon 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.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.ui.strings.CommonStrings @@ -75,21 +75,14 @@ fun PreferencesRootView( emojiPickerRenderer: EmojiPickerRenderer, onBackClick: () -> Unit, onAddAccountClick: () -> Unit, - onSecureBackupClick: () -> Unit, - onManageAccountClick: (url: String) -> Unit, - onLinkNewDeviceClick: () -> Unit, onOpenAnalytics: () -> Unit, - onOpenRageShake: () -> Unit, onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, onOpenDeveloperSettings: () -> Unit, - onOpenAdvancedSettings: () -> Unit, + onOpenMediaSettings: () -> Unit, + onOpenLocationSettings: () -> Unit, onOpenLabs: () -> Unit, - onOpenNotificationSettings: () -> Unit, - onOpenUserProfile: (MatrixUser) -> Unit, - onOpenBlockedUsers: () -> Unit, - onSignOutClick: () -> Unit, - onDeactivateClick: () -> Unit, + onOpenAccountSettings: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -103,7 +96,7 @@ fun PreferencesRootView( ) { UserPreferences( modifier = Modifier.clickable { - onOpenUserProfile(state.myUser) + onOpenAccountSettings() }, matrixUser = state.myUser, ) @@ -123,32 +116,20 @@ fun PreferencesRootView( thickness = 8.dp, color = ElementTheme.colors.bgSubtleSecondary, ) - // 'Account' section - ManageAccountSection( + // 'App settings' section + AppSettingsSection( state = state, - onManageAccountClick = onManageAccountClick, - onLinkNewDeviceClick = onLinkNewDeviceClick, - onOpenBlockedUsers = onOpenBlockedUsers - ) - // 'Manage my app' section - ManageAppSection( - state = state, - onOpenNotificationSettings = onOpenNotificationSettings, onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClick = onSecureBackupClick, + onOpenMediaSettings = onOpenMediaSettings, + onOpenLocationSettings = onOpenLocationSettings, ) - // General section GeneralSection( state = state, onOpenAbout = onOpenAbout, onOpenAnalytics = onOpenAnalytics, - onOpenRageShake = onOpenRageShake, - onOpenAdvancedSettings = onOpenAdvancedSettings, onOpenDeveloperSettings = onOpenDeveloperSettings, onOpenLabs = onOpenLabs, - onSignOutClick = onSignOutClick, - onDeactivateClick = onDeactivateClick, ) // Version Footer( @@ -171,8 +152,8 @@ private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) }, ) @@ -267,10 +248,10 @@ private fun ColumnScope.MultiAccountSection( state.otherSessions.forEach { matrixUser -> MatrixUserRow( modifier = Modifier - .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) - } - .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), + .clickable { + state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + } + .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), matrixUser = matrixUser, avatarSize = AvatarSize.AccountItem, verticalSpaceWidth = 16.dp, @@ -294,66 +275,42 @@ private fun AddAccountItem(onAddAccountClick: () -> Unit) { } @Composable -private fun ColumnScope.ManageAppSection( +private fun AppSettingsSection( state: PreferencesRootState, - onOpenNotificationSettings: () -> Unit, onOpenLockScreenSettings: () -> Unit, - onSecureBackupClick: () -> Unit, + onOpenLocationSettings: () -> Unit, + onOpenMediaSettings: () -> Unit, ) { - ListItem( - content = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), - onClick = onOpenNotificationSettings, - ) - ListItem( - content = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), - onClick = onOpenLockScreenSettings, - ) - if (state.showSecureBackup) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_encryption)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())), - trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, - onClick = onSecureBackupClick, + PreferenceCategory( + title = stringResource(CommonStrings.common_app_settings), + showTopDivider = false, + ) { + PreferenceDropdown( + icon = CompoundIcons.DarkMode(), + dropDownIcon = CompoundIcons.ChevronUpDown(), + title = stringResource(id = CommonStrings.common_appearance), + selectedOption = state.theme, + options = state.availableThemeOptions, + onSelectOption = { themeOption -> + state.eventSink(PreferencesRootEvent.SetTheme(themeOption)) + } ) - } - HorizontalDivider() -} - -@Composable -private fun ColumnScope.ManageAccountSection( - state: PreferencesRootState, - onManageAccountClick: (url: String) -> Unit, - onLinkNewDeviceClick: () -> Unit, - onOpenBlockedUsers: () -> Unit, -) { - state.accountManagementUrl?.let { url -> ListItem( - content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), - trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClick(url) }, + content = { Text(stringResource(id = CommonStrings.common_media_upload_quality)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), + onClick = onOpenMediaSettings, ) - } - if (state.showLinkNewDevice) { ListItem( - content = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), - onClick = onLinkNewDeviceClick, + content = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onOpenLockScreenSettings, ) - } - if (state.showBlockedUsersItem) { ListItem( - content = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), - onClick = onOpenBlockedUsers, - trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()), + content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), + onClick = onOpenLocationSettings, ) } - if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) { - HorizontalDivider() - } } @Composable @@ -361,18 +318,20 @@ private fun ColumnScope.GeneralSection( state: PreferencesRootState, onOpenAbout: () -> Unit, onOpenAnalytics: () -> Unit, - onOpenRageShake: () -> Unit, - onOpenAdvancedSettings: () -> Unit, onOpenLabs: () -> Unit, onOpenDeveloperSettings: () -> Unit, - onSignOutClick: () -> Unit, - onDeactivateClick: () -> Unit, ) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), - onClick = onOpenAdvancedSettings, + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, ) + if (state.showAnalyticsSettings) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_analytics)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())), + onClick = onOpenAnalytics, + ) + } if (state.showLabsItem) { ListItem( content = { Text(stringResource(id = R.string.screen_labs_title)) }, @@ -385,35 +344,6 @@ private fun ColumnScope.GeneralSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), onClick = onOpenAbout, ) - if (state.canReportBug) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), - onClick = onOpenRageShake - ) - } - if (state.showAnalyticsSettings) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_analytics)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())), - onClick = onOpenAnalytics, - ) - } - HorizontalDivider() - ListItem( - content = { Text(stringResource(id = CommonStrings.action_signout)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), - style = ListItemStyle.Destructive, - onClick = onSignOutClick, - ) - if (state.canDeactivateAccount) { - ListItem( - content = { Text(stringResource(id = CommonStrings.action_delete_account)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), - style = ListItemStyle.Destructive, - onClick = onDeactivateClick, - ) - } // Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry AnimatedVisibility( visible = state.showDeveloperSettings, @@ -429,9 +359,9 @@ private fun ColumnScope.Footer( ) { Text( modifier = Modifier - .align(Alignment.CenterHorizontally) - .clickable(enabled = onClick != null, onClick = onClick ?: {}) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp), + .align(Alignment.CenterHorizontally) + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp), textAlign = TextAlign.Center, text = version, style = ElementTheme.typography.fontBodySmRegular, @@ -471,19 +401,12 @@ private fun ContentToPreview(state: PreferencesRootState) { onBackClick = {}, onAddAccountClick = {}, onOpenAnalytics = {}, - onOpenRageShake = {}, onOpenDeveloperSettings = {}, - onOpenAdvancedSettings = {}, + onOpenLocationSettings = {}, + onOpenMediaSettings = {}, onOpenLabs = {}, onOpenAbout = {}, - onSecureBackupClick = {}, - onManageAccountClick = {}, - onLinkNewDeviceClick = {}, - onOpenNotificationSettings = {}, onOpenLockScreenSettings = {}, - onOpenUserProfile = {}, - onOpenBlockedUsers = {}, - onSignOutClick = {}, - onDeactivateClick = {}, + onOpenAccountSettings = {}, ) } diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 9da592170e..f1d8baf603 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -37,8 +37,8 @@ "If turned off, you won’t be able to send or receive read receipts or typing notifications." "Always hide" "Always show" - "In private rooms" - "A hidden media can always be shown by tapping on it" + "Show in private rooms" + "Hidden media can always be shown by tapping on it." "Show media in timeline" "Enable option to view message source in the timeline." "You have no blocked users" @@ -61,6 +61,8 @@ "Try out our latest ideas in development. These features are not finalised; they may be unstable, may change." "Feeling experimental?" "Labs" + "Invites" + "Your appearance" "Additional settings" "Call ringtone" "Audio and video calls" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt similarity index 90% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt index 2ee594e8b9..46ed8c7d0a 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.media import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow @@ -71,11 +71,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(isDeveloperModeEnabled).isFalse() - eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(true)) + eventSink(MediaSettingsEvent.SetDeveloperModeEnabled(true)) } with(awaitItem()) { assertThat(isDeveloperModeEnabled).isTrue() - eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(false)) + eventSink(MediaSettingsEvent.SetDeveloperModeEnabled(false)) } with(awaitItem()) { assertThat(isDeveloperModeEnabled).isFalse() @@ -94,11 +94,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(isSharePresenceEnabled).isTrue() - eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(false)) + eventSink(MediaSettingsEvent.SetSharePresenceEnabled(false)) } with(awaitItem()) { assertThat(isSharePresenceEnabled).isFalse() - eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(true)) + eventSink(MediaSettingsEvent.SetSharePresenceEnabled(true)) } with(awaitItem()) { assertThat(isSharePresenceEnabled).isTrue() @@ -117,11 +117,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - eventSink(AdvancedSettingsEvent.SetCompressMedia(false)) + eventSink(MediaSettingsEvent.SetCompressMedia(false)) } with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() - eventSink(AdvancedSettingsEvent.SetCompressMedia(true)) + eventSink(MediaSettingsEvent.SetCompressMedia(true)) } with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() @@ -144,11 +144,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() - eventSink(AdvancedSettingsEvent.SetCompressImages(false)) + eventSink(MediaSettingsEvent.SetCompressImages(false)) } with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() - eventSink(AdvancedSettingsEvent.SetCompressImages(true)) + eventSink(MediaSettingsEvent.SetCompressImages(true)) } with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() @@ -171,11 +171,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) - eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) } with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) - eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) + eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) } with(awaitItem()) { assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) @@ -194,15 +194,15 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(theme).isEqualTo(ThemeOption.System) - eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.Dark)) + eventSink(MediaSettingsEvent.SetTheme(ThemeOption.Dark)) } with(awaitItem()) { assertThat(theme).isEqualTo(ThemeOption.Dark) - eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.Light)) + eventSink(MediaSettingsEvent.SetTheme(ThemeOption.Light)) } with(awaitItem()) { assertThat(theme).isEqualTo(ThemeOption.Light) - eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.System)) + eventSink(MediaSettingsEvent.SetTheme(ThemeOption.System)) } with(awaitItem()) { assertThat(theme).isEqualTo(ThemeOption.System) @@ -242,7 +242,7 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) - eventSink(AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) + eventSink(MediaSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) } with(awaitItem()) { assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42) @@ -298,11 +298,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(true)) + eventSink(MediaSettingsEvent.SetHideInviteAvatars(true)) } with(awaitItem()) { assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() - eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(false)) + eventSink(MediaSettingsEvent.SetHideInviteAvatars(false)) } with(awaitItem()) { assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() @@ -323,11 +323,11 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + eventSink(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) } with(awaitItem()) { assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) - eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + eventSink(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) } with(awaitItem()) { assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) @@ -383,7 +383,7 @@ class AdvancedSettingsPresenterTest { sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), - ) = AdvancedSettingsPresenter( + ) = MediaSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt similarity index 77% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt index 52af7d5927..4374ad0f04 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt @@ -8,7 +8,7 @@ @file:OptIn(ExperimentalTestApi::class) -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.media import androidx.activity.ComponentActivity import androidx.compose.runtime.CompositionLocalProvider @@ -39,10 +39,10 @@ import org.robolectric.annotation.Config class AdvancedSettingsViewTest : RobolectricTest() { @Test fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder ), onBackClick = it @@ -53,21 +53,21 @@ class AdvancedSettingsViewTest : RobolectricTest() { @Test fun `clicking on other theme emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, ), ) clickOn(CommonStrings.common_appearance) clickOn(R.string.theme_dark) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTheme(ThemeOption.Dark)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetTheme(ThemeOption.Dark)) } @Test fun `black theme is shown when available`() = runAndroidComposeUiTest { setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( availableThemeOptions = ThemeOption.entries.toImmutableList(), ), ) @@ -81,7 +81,7 @@ class AdvancedSettingsViewTest : RobolectricTest() { @Test fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest { setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(), ), ) @@ -91,40 +91,40 @@ class AdvancedSettingsViewTest : RobolectricTest() { @Test fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, ), ) clickOn(CommonStrings.action_view_source) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetDeveloperModeEnabled(true)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetDeveloperModeEnabled(true)) } @Test fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, ), ) clickOn(R.string.screen_advanced_settings_share_presence) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetSharePresenceEnabled(true)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetSharePresenceEnabled(true)) } @Test fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() val analyticsService = FakeAnalyticsService() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, ), analyticsService = analyticsService ) clickOn(R.string.screen_advanced_settings_media_compression_description) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetCompressMedia(true)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true)) assertThat(analyticsService.capturedEvents).isEqualTo( listOf( Interaction( @@ -136,17 +136,17 @@ class AdvancedSettingsViewTest : RobolectricTest() { @Test fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() val analyticsService = FakeAnalyticsService() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), eventSink = eventsRecorder, ), analyticsService = analyticsService ) clickOn(R.string.screen_advanced_settings_media_compression_description) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetCompressMedia(false)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(false)) assertThat(analyticsService.capturedEvents).isEqualTo( listOf( Interaction( @@ -159,65 +159,65 @@ class AdvancedSettingsViewTest : RobolectricTest() { @Test @Config(qualifiers = "h1080dp") fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, hideInviteAvatars = false ), ) clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetHideInviteAvatars(true)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetHideInviteAvatars(true)) } @Test @Config(qualifiers = "h1080dp") fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, timelineMediaPreviewValue = MediaPreviewValue.On ), ) clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) } @Test @Config(qualifiers = "h1080dp") fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, timelineMediaPreviewValue = MediaPreviewValue.On ), ) clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) } @Test @Config(qualifiers = "h1080dp") fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, timelineMediaPreviewValue = MediaPreviewValue.Off ), ) clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) } @Test @Config(qualifiers = "h1080dp") fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, hideInviteAvatars = false, setHideInviteAvatarsAction = AsyncAction.Loading @@ -230,9 +230,9 @@ class AdvancedSettingsViewTest : RobolectricTest() { @Test @Config(qualifiers = "h1080dp") fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) setAdvancedSettingsView( - state = aAdvancedSettingsState( + state = aMediaSettingsState( eventSink = eventsRecorder, timelineMediaPreviewValue = MediaPreviewValue.On, setTimelineMediaPreviewAction = AsyncAction.Loading @@ -245,7 +245,7 @@ class AdvancedSettingsViewTest : RobolectricTest() { } private fun AndroidComposeUiTest.setAdvancedSettingsView( - state: AdvancedSettingsState, + state: MediaSettingsState, analyticsService: AnalyticsService = FakeAnalyticsService(), onBackClick: () -> Unit = EnsureNeverCalled(), onOpenAppSettings: () -> Unit = EnsureNeverCalled(), @@ -254,7 +254,7 @@ private fun AndroidComposeUiTest.setAdvancedSettingsView( CompositionLocalProvider( LocalAnalyticsService provides analyticsService, ) { - AdvancedSettingsView( + MediaSettingsView( state = state, onBackClick = onBackClick, onOpenAppSettingsClick = onOpenAppSettings diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/FakeMediaPreviewConfigStateStore.kt similarity index 97% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/FakeMediaPreviewConfigStateStore.kt index b07e8b7a55..87b48e7d25 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/FakeMediaPreviewConfigStateStore.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.media import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaPreviewConfigStateStoreTest.kt similarity index 99% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaPreviewConfigStateStoreTest.kt index becd6b1a2f..e00c9c586a 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaPreviewConfigStateStoreTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.media import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow 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 df1633f986..a7cf928edc 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 @@ -544,7 +544,7 @@ private fun AndroidComposeUiTest.setView( onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, onOpenDeveloperSettings = onOpenDeveloperSettings, - onOpenAdvancedSettings = onOpenAdvancedSettings, + onOpenMediaSettings = onOpenAdvancedSettings, onOpenLabs = onOpenLabs, onOpenNotificationSettings = onOpenNotificationSettings, onOpenUserProfile = onOpenUserProfile, diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt index dd002814b1..bf339d7592 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -121,7 +121,7 @@ private fun RoomAliasResolverContent( PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp) }, title = { - RoomPreviewAliasAtom(roomAlias.value) + CopiableTextAtom(roomAlias.value) }, subtitle = { if (isLoading) { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CopiableTextAtom.kt similarity index 91% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CopiableTextAtom.kt index ad70fc3ba8..2bc1f17f1d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CopiableTextAtom.kt @@ -35,8 +35,8 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @Composable -fun RoomPreviewAliasAtom( - alias: String, +fun CopiableTextAtom( + text: String, modifier: Modifier = Modifier, copiable: Boolean = true ) { @@ -46,7 +46,7 @@ fun RoomPreviewAliasAtom( modifier = modifier .clickable(enabled = copiable) { coroutineScope.launch { - val clipData = ClipData.newPlainText(alias, alias) + val clipData = ClipData.newPlainText(text, text) clipboard.setClipEntry(clipData.toClipEntry()) } }, @@ -55,7 +55,7 @@ fun RoomPreviewAliasAtom( ) { Text( modifier = Modifier.weight(weight = 1f, fill = false), - text = alias, + text = text, style = ElementTheme.typography.fontBodyLgRegular, textAlign = TextAlign.Center, maxLines = 1, @@ -75,9 +75,9 @@ fun RoomPreviewAliasAtom( @PreviewsDayNight @Composable -internal fun RoomPreviewAliasAtomPreview() = ElementPreview { - RoomPreviewAliasAtom( - alias = "#room-alias:matrix.org", +internal fun CopiableTextAtomPreview() = ElementPreview { + CopiableTextAtom( + text = "#room-alias:matrix.org", copiable = true ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt index 1072dd41d5..093791a71f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt @@ -55,6 +55,7 @@ fun PreferenceDropdown( supportingText: String? = null, enabled: Boolean = true, icon: ImageVector? = null, + dropDownIcon: ImageVector = CompoundIcons.ChevronDown(), @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, ) { @@ -84,13 +85,14 @@ fun PreferenceDropdown( trailingContent = ListItemContent.Custom( content = { enabled -> DropdownTrailingContent( + dropDownIcon = dropDownIcon, selectedOption = selectedOption, options = options, onSelectOption = onSelectOption, expanded = isDropdownExpanded, onExpandedChange = { isDropdownExpanded = it }, enabled = enabled, - modifier = Modifier.fillMaxSize(0.3f) + modifier = Modifier.fillMaxSize(0.3f), ) } ), @@ -119,6 +121,7 @@ private fun DropdownTrailingContent( onSelectOption: (T) -> Unit, enabled: Boolean, modifier: Modifier = Modifier, + dropDownIcon: ImageVector = CompoundIcons.ChevronDown(), ) { Row( modifier = modifier, @@ -135,7 +138,7 @@ private fun DropdownTrailingContent( modifier = Modifier.weight(1f), ) Icon( - imageVector = CompoundIcons.ChevronDown(), + imageVector = dropDownIcon, contentDescription = null, tint = enabled.toIconSecondaryEnabledColor(), ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt index 8a833cee44..68c53c0685 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom +import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism @@ -87,7 +87,7 @@ fun SpaceHeaderView( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (alias != null) { - RoomPreviewAliasAtom(alias = alias.value) + CopiableTextAtom(text = alias.value) } SpaceInfoRow(visibility = visibility) } 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 new file mode 100644 index 0000000000..482dd1aac6 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserHeaderView.kt @@ -0,0 +1,92 @@ +/* + * 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 = {}, + ) +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 9a51005943..1b89d51482 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -223,6 +223,7 @@ "Upgrade available" "About" "Acceptable use policy" + "Account settings" "Add an account" "Add account" "Adding caption" @@ -231,6 +232,7 @@ "Analytics" "You left the room" "You were logged out of the session" + "App settings" "Appearance" "Audio" "Beta" @@ -306,10 +308,12 @@ Reason: %1$s." "Live location ended" "Loading…" "Loading more…" + "Location sharing" "%d other" "%d others" + "Media upload quality" "%1$d Member" "%1$d Members" @@ -319,6 +323,7 @@ Reason: %1$s." "Message failed to send" "Message layout" "Message removed" + "Moderation & safety" "Modern" "Mute" "Name" @@ -419,6 +424,7 @@ Reason: %1$s." "Success" "Suggested" "Suggestions" + "Switch accounts" "Syncing" "Text" "Third-party notices" @@ -458,6 +464,7 @@ Reason: %1$s." "Waiting for live location…" "Anyone can see history" "You" + "Your account" "Download not available." "Something went wrong" "Scanning…" @@ -552,12 +559,6 @@ Are you sure you want to continue?" "One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices." "Your message was not sent because you have not verified one or more of your devices" "Edit Admins or Owners" - "Are you sure you wish to remove this message?" - "Reason (optional)" - "Enter…" - "Reason…" - "(Optional)" - "Confirm removal" "Failed processing media to upload, please try again." "Could not retrieve user details" "Message in %1$s" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 98e997c520..40b4ab299e 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -337,6 +337,7 @@ "screen_edit_profile_.*", "screen_notification_settings_.*", "screen\\.notification_settings\\..*", + "screen\\.moderation_and_safety\\..*", "screen_blocked_users_.*", "full_screen_intent_banner_.*", "troubleshoot_notifications_entry_point_.*",