Logout screen: add account info.

This commit is contained in:
Benoit Marty
2026-09-28 09:15:48 +02:00
parent 3de59bbd88
commit e1ec0b3c55
11 changed files with 318 additions and 2 deletions
+1
View File
@@ -31,6 +31,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
@@ -41,6 +41,7 @@ class LogoutPresenter(
@Composable @Composable
override fun present(): LogoutState { override fun present(): LogoutState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val currentUser by matrixClient.userProfile.collectAsState()
val logoutAction: MutableState<AsyncAction<Unit>> = remember { val logoutAction: MutableState<AsyncAction<Unit>> = remember {
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
@@ -90,6 +91,7 @@ class LogoutPresenter(
} }
return LogoutState( return LogoutState(
currentUser = currentUser,
isLastDevice = isLastDevice, isLastDevice = isLastDevice,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
@@ -12,8 +12,10 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
data class LogoutState( data class LogoutState(
val currentUser: MatrixUser,
val isLastDevice: Boolean, val isLastDevice: Boolean,
val backupState: BackupState, val backupState: BackupState,
val doesBackupExistOnServer: Boolean, val doesBackupExistOnServer: Boolean,
@@ -14,11 +14,14 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.SteadyStateException import io.element.android.libraries.matrix.api.encryption.SteadyStateException
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> { open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> {
override val values: Sequence<LogoutState> override val values: Sequence<LogoutState>
get() = sequenceOf( get() = sequenceOf(
aLogoutState(), aLogoutState(),
aLogoutState(currentUser = aMatrixUser(id = "@alice:server.org", displayName = null)),
aLogoutState(isLastDevice = true), aLogoutState(isLastDevice = true),
aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)), aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)),
aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done), aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done),
@@ -43,6 +46,7 @@ open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> {
} }
fun aLogoutState( fun aLogoutState(
currentUser: MatrixUser = aMatrixUser(),
isLastDevice: Boolean = false, isLastDevice: Boolean = false,
backupState: BackupState = BackupState.ENABLED, backupState: BackupState = BackupState.ENABLED,
doesBackupExistOnServer: Boolean = true, doesBackupExistOnServer: Boolean = true,
@@ -52,6 +56,7 @@ fun aLogoutState(
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized, logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LogoutEvent) -> Unit = {}, eventSink: (LogoutEvent) -> Unit = {},
) = LogoutState( ) = LogoutState(
currentUser = currentUser,
isLastDevice = isLastDevice, isLastDevice = isLastDevice,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer, doesBackupExistOnServer = doesBackupExistOnServer,
@@ -11,7 +11,9 @@ package io.element.android.features.logout.impl
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -38,6 +40,9 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.SteadyStateException import io.element.android.libraries.matrix.api.encryption.SteadyStateException
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -157,9 +162,16 @@ private fun Content(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 60.dp, start = 20.dp, end = 20.dp), .padding(top = 8.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
AccountInfoCard(
AccountInfoCardState(
matrixUser = state.currentUser,
mode = AccountInfoCardMode.Simple,
)
)
Spacer(modifier = Modifier.height(16.dp))
when (state.backupUploadState) { when (state.backupUploadState) {
is BackupUploadState.Uploading -> { is BackupUploadState.Uploading -> {
LinearProgressIndicator( LinearProgressIndicator(
@@ -20,7 +20,9 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
@@ -39,11 +41,18 @@ class LogoutPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createLogoutPresenter() val presenter = createLogoutPresenter(
matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID,
userDisplayName = "Alice",
userAvatarUrl = "mxc://avatar",
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.currentUser).isEqualTo(MatrixUser(A_SESSION_ID, "Alice", "mxc://avatar"))
assertThat(initialState.isLastDevice).isFalse() assertThat(initialState.isLastDevice).isFalse()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.doesBackupExistOnServer).isTrue() assertThat(initialState.doesBackupExistOnServer).isTrue()
@@ -13,8 +13,11 @@ package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@@ -25,8 +28,22 @@ import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.robolectric.annotation.Config
class LogoutViewTest : RobolectricTest() { class LogoutViewTest : RobolectricTest() {
@Test
@Config(qualifiers = "h1024dp")
fun `the current user is displayed`() = runAndroidComposeUiTest {
setLogoutView(
aLogoutState(
currentUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
eventSink = EventsRecorder(expectEvents = false),
),
)
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("@alice:server.org").assertIsDisplayed()
}
@Test @Test
fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest { fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvent>() val eventsRecorder = EventsRecorder<LogoutEvent>()
@@ -31,6 +31,7 @@ enum class AvatarSize(val dp: Dp) {
UserHeader(96.dp), UserHeader(96.dp),
UserListItem(36.dp), UserListItem(36.dp),
AccountInfoUser(36.dp),
SelectedUser(52.dp), SelectedUser(52.dp),
SelectedRoom(56.dp), SelectedRoom(56.dp),
@@ -0,0 +1,193 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components.accountinfo
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5873-996
*/
@Composable
fun AccountInfoCard(
state: AccountInfoCardState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp))
.padding(20.dp),
) {
when (state.mode) {
AccountInfoCardMode.Simple -> Unit
is AccountInfoCardMode.DeviceVerification -> Unit
is AccountInfoCardMode.UserVerification -> {
Text(
// TODO
text = "User requesting verification:",
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
AccountInfoCardUser(
matrixUser = state.matrixUser,
)
when (state.mode) {
AccountInfoCardMode.Simple -> Unit
is AccountInfoCardMode.DeviceVerification -> {
AccountInfoSeparator()
DeviceInfoRow(state.mode)
}
is AccountInfoCardMode.UserVerification -> {
AccountInfoSeparator()
AccountInfoCardUser(
matrixUser = state.mode.otherUser,
)
}
}
}
}
@Composable
private fun AccountInfoSeparator() {
HorizontalDivider(
modifier = Modifier.padding(vertical = 20.dp),
color = ElementTheme.colors.separatorPrimary,
)
}
@Composable
private fun DeviceInfoRow(
mode: AccountInfoCardMode.DeviceVerification,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RoundedIconAtom(
modifier = Modifier,
size = RoundedIconAtomSize.Big,
resourceId = CompoundDrawables.ic_compound_devices
)
Text(
text = mode.deviceName ?: mode.deviceId.value,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextWithLabelMolecule(
// TODO i18n
label = "Signed in",
text = mode.signInFormattedTimestamp,
modifier = Modifier.weight(1f),
)
TextWithLabelMolecule(
label = stringResource(CommonStrings.common_device_id),
text = mode.deviceId.value,
modifier = Modifier.weight(1f),
spellText = true,
)
}
}
}
@Composable
private fun AccountInfoCardUser(matrixUser: MatrixUser) {
val subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.AccountInfoUser),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgMedium,
)
// Id
subtext?.let {
Text(
text = subtext,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AccountInfoCardPreview(
@PreviewParameter(AccountInfoCardStatePreviewParam::class) state: AccountInfoCardState
) = ElementPreview {
// Add some padding to view the border
Box(modifier = Modifier.padding(16.dp)) {
AccountInfoCard(state)
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components.accountinfo
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
data class AccountInfoCardState(
val matrixUser: MatrixUser,
val mode: AccountInfoCardMode,
)
@Immutable
sealed interface AccountInfoCardMode {
data object Simple : AccountInfoCardMode
data class UserVerification(
val otherUser: MatrixUser,
) : AccountInfoCardMode
// TODO Replace SessionDetailsView with this component
data class DeviceVerification(
val deviceName: String?,
val deviceId: DeviceId,
val signInFormattedTimestamp: String,
) : AccountInfoCardMode
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components.accountinfo
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
open class AccountInfoCardStatePreviewParam : PreviewParameterProvider<AccountInfoCardState> {
override val values: Sequence<AccountInfoCardState>
get() = sequenceOf(
anAccountInfoCardState(),
anAccountInfoCardState(aMatrixUser(displayName = null)),
anAccountInfoCardState(
mode = AccountInfoCardMode.UserVerification(
otherUser = aMatrixUser(displayName = "Other User"),
)
),
anAccountInfoCardState(
mode = AccountInfoCardMode.DeviceVerification(
deviceName = "My Device",
deviceId = DeviceId("DEVICEID"),
signInFormattedTimestamp = "2024-06-01 12:00:00",
)
),
)
}
private fun anAccountInfoCardState(
matrixUser: MatrixUser = aMatrixUser(),
mode: AccountInfoCardMode = AccountInfoCardMode.Simple,
) = AccountInfoCardState(
matrixUser = matrixUser,
mode = mode,
)