Move user status next to the current account header.

Also make the other account section collapsable
This commit is contained in:
Benoit Marty
2026-09-18 17:03:27 +02:00
parent 5cac4767f1
commit ae36cc3b1e
13 changed files with 287 additions and 31 deletions
@@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
}
@@ -34,6 +34,7 @@ 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
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
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>,
) : Presenter<PreferencesRootState> {
@@ -71,6 +73,9 @@ class PreferencesRootPresenter(
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
val isOtherAccountsSectionExpanded by remember {
appPreferencesStore.isOtherAccountsExpandedFlow()
}.collectAsState(initial = true)
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
@@ -138,6 +143,9 @@ class PreferencesRootPresenter(
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
}
}
}
@@ -146,6 +154,7 @@ class PreferencesRootPresenter(
userStatusState = userStatusState,
version = remember { versionFormatter.get() },
isMultiAccountEnabled = isMultiAccountEnabled,
isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded,
otherSessions = otherSessions,
showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
@@ -19,6 +19,7 @@ data class PreferencesRootState(
val userStatusState: UserStatusState?,
val version: String,
val isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>,
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
@@ -47,6 +47,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
),
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3),
accountManagementUrl = "aUrl",
showSecureBackup = true,
@@ -74,6 +75,7 @@ fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(),
version: String = "Version 1.1 (1)",
isMultiAccountEnabled: Boolean = false,
isOtherAccountsSectionExpanded: Boolean = false,
otherSessions: List<MatrixUser> = emptyList(),
showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false,
@@ -93,6 +95,7 @@ fun aPreferencesRootState(
myUser = myUser,
version = version,
isMultiAccountEnabled = isMultiAccountEnabled,
isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded,
otherSessions = otherSessions.toImmutableList(),
showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge,
@@ -9,17 +9,28 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -33,13 +44,16 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
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.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.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
@@ -51,7 +65,9 @@ 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
import kotlinx.collections.immutable.toImmutableList
@Composable
fun PreferencesRootView(
@@ -91,19 +107,22 @@ fun PreferencesRootView(
},
matrixUser = state.myUser,
)
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
)
}
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
}
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
// 'Account' section
ManageAccountSection(
state = state,
@@ -163,25 +182,21 @@ private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<U
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
emojiPickerRenderer: EmojiPickerRenderer,
showTopDivider: Boolean,
) {
if (showTopDivider) {
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
UserStatusView(
state = userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759
*/
@Composable
private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState,
@@ -191,18 +206,88 @@ private fun ColumnScope.MultiAccountSection(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
if (state.otherSessions.isEmpty()) {
AddAccountItem(onAddAccountClick)
} else {
val expandedStateDescription = if (state.isOtherAccountsSectionExpanded) {
stringResource(CommonStrings.a11y_state_expanded)
} else {
stringResource(CommonStrings.a11y_state_collapsed)
}
ListItem(
modifier = Modifier.semantics {
stateDescription = expandedStateDescription
},
content = { Text(stringResource(CommonStrings.common_switch_account)) },
onClick = { state.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded) },
trailingContent = ListItemContent.Custom { _ ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = !state.isOtherAccountsSectionExpanded,
enter = fadeIn(),
exit = fadeOut(),
) {
AvatarRow(
avatarDataList = state.otherSessions
.take(3)
.map { it.getAvatarData(AvatarSize.OtherAccountItem) }
.toImmutableList(),
avatarType = AvatarType.User,
lastOnTop = true,
)
}
// Animate the chevron icon to rotate when the section is expanded/collapsed
val rotation: Float by animateFloatAsState(
targetValue = if (state.isOtherAccountsSectionExpanded) 180f else 0f,
animationSpec = tween(
delayMillis = 0,
durationMillis = 300,
),
label = "chevron"
)
Icon(
modifier = Modifier.rotate(rotation),
imageVector = if (state.isOtherAccountsSectionExpanded) {
CompoundIcons.ChevronUp()
} else {
CompoundIcons.ChevronDown()
},
contentDescription = null,
)
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
},
)
AnimatedVisibility(
visible = state.isOtherAccountsSectionExpanded,
) {
Column {
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier
.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,
)
}
AddAccountItem(onAddAccountClick)
}
}
}
}
@Composable
private fun AddAccountItem(onAddAccountClick: () -> Unit) {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
content = {
@@ -210,10 +295,6 @@ private fun ColumnScope.MultiAccountSection(
},
onClick = onAddAccountClick,
)
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
@Composable
@@ -37,17 +37,21 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -82,6 +86,7 @@ class PreferencesRootPresenterTest {
)
assertThat(initialState.version).isEqualTo("A Version")
assertThat(initialState.isMultiAccountEnabled).isFalse()
assertThat(initialState.isOtherAccountsSectionExpanded).isTrue()
assertThat(initialState.otherSessions).isEmpty()
assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem()
@@ -304,6 +309,43 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - other accounts section is collapsed if the preference says so`() = runTest {
createPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
appPreferencesStore = InMemoryAppPreferencesStore(isOtherAccountsExpanded = false),
).test {
consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded }
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggle other accounts expanded stores the new value`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore()
createPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
appPreferencesStore = appPreferencesStore,
).test {
val initialState = awaitFirstItem()
assertThat(initialState.isOtherAccountsSectionExpanded).isTrue()
initialState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded)
val collapsedState = consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded }.last()
assertThat(appPreferencesStore.isOtherAccountsExpandedFlow().first()).isFalse()
// Toggling again must expand the section back
collapsedState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded)
consumeItemsUntilPredicate { it.isOtherAccountsSectionExpanded }
assertThat(appPreferencesStore.isOtherAccountsExpandedFlow().first()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - link new device`() = runTest {
createPresenter(
@@ -333,6 +375,7 @@ class PreferencesRootPresenterTest {
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(),
) = PreferencesRootPresenter(
matrixClient = matrixClient,
@@ -346,6 +389,7 @@ class PreferencesRootPresenterTest {
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
sessionStore = sessionStore,
appPreferencesStore = appPreferencesStore,
sessionEnterpriseService = sessionEnterpriseService,
userStatusPresenter = { aUserStatusState() },
)
@@ -27,6 +27,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.assertNoNodeWithText
import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
@@ -71,6 +73,7 @@ class PreferencesRootViewTest : RobolectricTest() {
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
@@ -84,6 +87,69 @@ class PreferencesRootViewTest : RobolectricTest() {
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
}
@Test
fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_switch_account)
eventsRecorder.assertSingle(PreferencesRootEvent.ToggleOtherAccountsExpanded)
}
@Test
fun `when the other accounts section is collapsed, other sessions and add account are not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
)
assertNodeWithTextIsDisplayed(CommonStrings.common_switch_account)
onNodeWithText("Bob").assertDoesNotExist()
assertNoNodeWithText(CommonStrings.common_add_another_account)
}
@Test
fun `click on Add account of the expanded section invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
onAddAccountClick = callback,
)
clickOn(CommonStrings.common_add_another_account)
}
}
@Test
fun `click on Add account invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)