mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Split settings: app settings / account settings
This commit is contained in:
committed by
Benoit Marty
parent
bec3780c9c
commit
9b2577f1dd
+3
-3
@@ -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))
|
||||
|
||||
+54
-20
@@ -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<PreferencesRootNode>(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<PreferencesRootNode>(buildContext, plugins = listOf(callback))
|
||||
createNode<PreferencesAccountNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.DeveloperSettings -> {
|
||||
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
|
||||
@@ -219,6 +247,12 @@ class PreferencesFlowNode(
|
||||
}
|
||||
createNode<LabsNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.ModerationAndSafety -> {
|
||||
createNode<ModerationAndSafetyNode>(buildContext)
|
||||
}
|
||||
NavTarget.LocationSettings -> {
|
||||
createNode<LocationSettingsNode>(buildContext)
|
||||
}
|
||||
NavTarget.About -> {
|
||||
val callback = object : AboutNode.Callback {
|
||||
override fun navigateToOssLicenses() {
|
||||
@@ -309,8 +343,8 @@ class PreferencesFlowNode(
|
||||
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
|
||||
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input, callback))
|
||||
}
|
||||
NavTarget.AdvancedSettings -> {
|
||||
createNode<AdvancedSettingsNode>(buildContext)
|
||||
NavTarget.MediaSettings -> {
|
||||
createNode<MediaSettingsNode>(buildContext)
|
||||
}
|
||||
is NavTarget.UserProfile -> {
|
||||
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
|
||||
|
||||
+96
@@ -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<Plugin>,
|
||||
private val presenter: PreferencesAccountPresenter,
|
||||
private val directLogoutView: DirectLogoutView,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun navigateToBugReport()
|
||||
fun navigateToSecureBackup()
|
||||
fun navigateToModerationAndSafety()
|
||||
fun navigateToNotificationSettings()
|
||||
fun navigateToLinkNewDevice()
|
||||
fun navigateToUserProfile(matrixUser: MatrixUser)
|
||||
fun navigateToBlockedUsers()
|
||||
fun startSignOutFlow()
|
||||
fun startAccountDeactivationFlow()
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
|
||||
private fun onManageAccountClick(
|
||||
activity: Activity,
|
||||
url: String?,
|
||||
isDark: Boolean,
|
||||
) {
|
||||
url?.let {
|
||||
activity.openUrlInChromeCustomTab(
|
||||
null,
|
||||
darkTheme = isDark,
|
||||
url = it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
PreferencesAccountView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = this::navigateUp,
|
||||
onAvatarClick = {
|
||||
// TODO
|
||||
},
|
||||
onOpenRageShake = callback::navigateToBugReport,
|
||||
onSecureBackupClick = callback::navigateToSecureBackup,
|
||||
onModerationAndSafetyClick = callback::navigateToModerationAndSafety,
|
||||
onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
|
||||
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
|
||||
onOpenNotificationSettings = callback::navigateToNotificationSettings,
|
||||
onEditProfileClick = callback::navigateToUserProfile,
|
||||
onOpenBlockedUsers = callback::navigateToBlockedUsers,
|
||||
onSignOutClick = {
|
||||
if (state.directLogoutState.canDoDirectSignOut) {
|
||||
state.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
|
||||
} else {
|
||||
callback.startSignOutFlow()
|
||||
}
|
||||
},
|
||||
onDeactivateClick = callback::startAccountDeactivationFlow
|
||||
)
|
||||
|
||||
directLogoutView.Render(state = state.directLogoutState)
|
||||
}
|
||||
}
|
||||
+111
@@ -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<DirectLogoutState>,
|
||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
) : Presenter<PreferencesAccountState> {
|
||||
@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<String?> = 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<String?>,
|
||||
) = launch {
|
||||
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null)
|
||||
.getOrNull()
|
||||
?.let {
|
||||
sessionEnterpriseService.tweakMasUrl(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+75
@@ -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<PreferencesAccountState> {
|
||||
override val values: Sequence<PreferencesAccountState>
|
||||
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,
|
||||
)
|
||||
+224
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
-24
@@ -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
|
||||
}
|
||||
-147
@@ -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<AdvancedSettingsState> {
|
||||
@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<Int?>(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<Boolean?>(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<MediaOptimizationState?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-67
@@ -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<ThemeOption>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
-62
@@ -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<AdvancedSettingsState> {
|
||||
override val values: Sequence<AdvancedSettingsState>
|
||||
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> = ThemeOption.entries.toImmutableList(),
|
||||
hideInviteAvatars: Boolean = false,
|
||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||
liveLocationMinimumDistanceUpdate: Int? = 50,
|
||||
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
setHideInviteAvatarsAction: AsyncAction<Unit> = 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
|
||||
)
|
||||
-446
@@ -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 */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+7
@@ -47,6 +47,9 @@ class AppDeveloperSettingsPresenter(
|
||||
) : Presenter<AppDeveloperSettingsState> {
|
||||
@Composable
|
||||
override fun present(): AppDeveloperSettingsState {
|
||||
val isDeveloperModeEnabled by remember {
|
||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||
}.collectAsState(initial = false)
|
||||
val rageshakeState = rageshakePresenter.present()
|
||||
val enabledFeatures = remember {
|
||||
mutableStateListOf<EnabledFeature>()
|
||||
@@ -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(
|
||||
|
||||
+1
@@ -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<FeatureUiModel>,
|
||||
val rageshakeState: RageshakePreferencesState,
|
||||
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
|
||||
|
||||
+5
@@ -24,14 +24,19 @@ open class AppDeveloperSettingsStatePreviewParam : PreviewParameterProvider<AppD
|
||||
baseUrl = "https://call.element.ahoy",
|
||||
)
|
||||
),
|
||||
anAppDeveloperSettingsState(
|
||||
isDeveloperModeEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun anAppDeveloperSettingsState(
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
|
||||
traceLogPacks: List<TraceLogPack> = emptyList(),
|
||||
eventSink: (AppDeveloperSettingsEvent) -> Unit = {},
|
||||
) = AppDeveloperSettingsState(
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
features = aFeatureUiModelList(),
|
||||
rageshakeState = aRageshakePreferencesState(),
|
||||
customElementCallBaseUrlState = customElementCallBaseUrlState,
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+12
@@ -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
|
||||
}
|
||||
+6
-7
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+45
@@ -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<LocationSettingsState> {
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
)
|
||||
+26
@@ -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<LocationSettingsState> {
|
||||
override val values: Sequence<LocationSettingsState>
|
||||
get() = sequenceOf(
|
||||
aLocationSettingsState(),
|
||||
aLocationSettingsState(liveLocationMinimumDistanceUpdate = 80),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLocationSettingsState(
|
||||
liveLocationMinimumDistanceUpdate: Int = 50,
|
||||
eventSink: (LocationSettingsEvent) -> Unit = {},
|
||||
) = LocationSettingsState(
|
||||
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
|
||||
eventSink = eventSink
|
||||
)
|
||||
+152
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+37
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+67
@@ -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<MediaSettingsState> {
|
||||
@Composable
|
||||
override fun present(): MediaSettingsState {
|
||||
val mediaOptimizationState by produceState<MediaOptimizationState?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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<MediaSettingsState> {
|
||||
override val values: Sequence<MediaSettingsState>
|
||||
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
|
||||
)
|
||||
+228
@@ -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 */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
+16
@@ -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
|
||||
}
|
||||
+36
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -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<ModerationAndSafetyState> {
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
)
|
||||
+42
@@ -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<ModerationAndSafetyState> {
|
||||
override val values: Sequence<ModerationAndSafetyState>
|
||||
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<Unit> = AsyncAction.Uninitialized,
|
||||
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (ModerationAndSafetyEvent) -> Unit = {},
|
||||
) = ModerationAndSafetyState(
|
||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||
mediaPreviewConfigState = MediaPreviewConfigState(
|
||||
hideInviteAvatars = hideInviteAvatars,
|
||||
timelineMediaPreviewValue = timelineMediaPreviewValue,
|
||||
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
|
||||
setHideInviteAvatarsAction = setHideInviteAvatarsAction
|
||||
),
|
||||
eventSink = eventSink
|
||||
)
|
||||
+180
@@ -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 = { }
|
||||
)
|
||||
}
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
+6
-51
@@ -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<Plugin>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-65
@@ -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<DirectLogoutState>,
|
||||
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<UserStatusState>,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
) : Presenter<PreferencesRootState> {
|
||||
@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<String?> = 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<String?>,
|
||||
) = launch {
|
||||
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null)
|
||||
.getOrNull()
|
||||
?.let {
|
||||
sessionEnterpriseService.tweakMasUrl(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-11
@@ -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<ThemeOption>,
|
||||
val userStatusState: UserStatusState?,
|
||||
val version: String,
|
||||
val isMultiAccountEnabled: Boolean,
|
||||
val isOtherAccountsSectionExpanded: Boolean,
|
||||
val otherSessions: ImmutableList<MatrixUser>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-33
@@ -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<Preferenc
|
||||
version = "Version 1.1 (1)",
|
||||
isMultiAccountEnabled = true,
|
||||
otherSessions = aMatrixUserList().drop(1).take(1),
|
||||
showSecureBackup = true,
|
||||
accountManagementUrl = "aUrl",
|
||||
canReportBug = true,
|
||||
showLinkNewDevice = true,
|
||||
showAnalyticsSettings = true,
|
||||
canDeactivateAccount = false,
|
||||
nbOfBlockedUsers = 3,
|
||||
showLabsItem = true,
|
||||
),
|
||||
aPreferencesRootState(
|
||||
myUser = aMatrixUser(displayName = null),
|
||||
isMultiAccountEnabled = true,
|
||||
showSecureBackup = true,
|
||||
canDeactivateAccount = true,
|
||||
),
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
isOtherAccountsSectionExpanded = true,
|
||||
otherSessions = aMatrixUserList().drop(1).take(3),
|
||||
accountManagementUrl = "aUrl",
|
||||
showSecureBackup = true,
|
||||
showSecureBackupBadge = true,
|
||||
),
|
||||
aPreferencesRootState(
|
||||
showLabsItem = true,
|
||||
canReportBug = true,
|
||||
nbOfBlockedUsers = 3,
|
||||
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
|
||||
),
|
||||
aPreferencesRootState(
|
||||
showLinkNewDevice = true,
|
||||
showAnalyticsSettings = true,
|
||||
showDeveloperSettings = true,
|
||||
canDeactivateAccount = true,
|
||||
),
|
||||
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
|
||||
// Minimal state
|
||||
@@ -76,18 +59,12 @@ fun aPreferencesRootState(
|
||||
version: String = "Version 1.1 (1)",
|
||||
isMultiAccountEnabled: Boolean = false,
|
||||
isOtherAccountsSectionExpanded: Boolean = false,
|
||||
theme: ThemeOption = ThemeOption.System,
|
||||
availableThemeOptions: List<ThemeOption> = ThemeOption.entries,
|
||||
otherSessions: List<MatrixUser> = 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,
|
||||
|
||||
+57
-134
@@ -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<U
|
||||
AsyncActionIndicator(
|
||||
asyncAction = updateStatusAction,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding(),
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding(),
|
||||
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
|
||||
failure = { _ -> 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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
<string name="screen_advanced_settings_share_presence_description">"If turned off, you won’t be able to send or receive read receipts or typing notifications."</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Always hide"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_always_show">"Always show"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"In private rooms"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_subtitle">"A hidden media can always be shown by tapping on it"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"Show in private rooms"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Hidden media can always be shown by tapping on it."</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_title">"Show media in timeline"</string>
|
||||
<string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string>
|
||||
<string name="screen_blocked_users_empty">"You have no blocked users"</string>
|
||||
@@ -61,6 +61,8 @@
|
||||
<string name="screen_labs_header_description">"Try out our latest ideas in development. These features are not finalised; they may be unstable, may change."</string>
|
||||
<string name="screen_labs_header_title">"Feeling experimental?"</string>
|
||||
<string name="screen_labs_title">"Labs"</string>
|
||||
<string name="screen_moderation_and_safety_invites_heading">"Invites"</string>
|
||||
<string name="screen_moderation_and_safety_share_presence_heading">"Your appearance"</string>
|
||||
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
|
||||
<string name="screen_notification_settings_call_ringtone_label">"Call ringtone"</string>
|
||||
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
|
||||
|
||||
+20
-20
@@ -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,
|
||||
+38
-38
@@ -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<AdvancedSettingsEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>(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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>()
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
|
||||
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<AdvancedSettingsEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>(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<AdvancedSettingsEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<MediaSettingsEvent>(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<ComponentActivity>.setAdvancedSettingsView(
|
||||
state: AdvancedSettingsState,
|
||||
state: MediaSettingsState,
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAppSettings: () -> Unit = EnsureNeverCalled(),
|
||||
@@ -254,7 +254,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
|
||||
CompositionLocalProvider(
|
||||
LocalAnalyticsService provides analyticsService,
|
||||
) {
|
||||
AdvancedSettingsView(
|
||||
MediaSettingsView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onOpenAppSettingsClick = onOpenAppSettings
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -544,7 +544,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
|
||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||
onOpenAbout = onOpenAbout,
|
||||
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
||||
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
||||
onOpenMediaSettings = onOpenAdvancedSettings,
|
||||
onOpenLabs = onOpenLabs,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onOpenUserProfile = onOpenUserProfile,
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user