mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Signed out screen: add account info.
Display the account which has been signed out, using the `AccountInfoCard`. The data comes from the session database, including the stored avatar thumbnail, since there is no Matrix client available to load the avatar. `SignedOutState.signedOutSession` is now a non-null `MatrixUser`, defaulting to a user with only the session id. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -9,5 +9,5 @@
|
||||
package io.element.android.features.signedout.impl
|
||||
|
||||
sealed interface SignedOutEvent {
|
||||
data object SignInAgain : SignedOutEvent
|
||||
data object Submit : SignedOutEvent
|
||||
}
|
||||
|
||||
+17
-6
@@ -19,8 +19,11 @@ import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
@@ -37,15 +40,16 @@ class SignedOutPresenter(
|
||||
@Composable
|
||||
override fun present(): SignedOutState {
|
||||
val signedOutSession by remember {
|
||||
sessionStore.sessionsFlow().map { sessions ->
|
||||
sessions.firstOrNull { it.userId == sessionId.value }
|
||||
}
|
||||
}.collectAsState(initial = null)
|
||||
sessionStore.sessionsFlow()
|
||||
// Ignore the removal of the session, to keep rendering the account data until the screen is closed
|
||||
.mapNotNull { sessions -> sessions.firstOrNull { it.userId == sessionId.value } }
|
||||
.map { it.toMatrixUser() }
|
||||
}.collectAsState(initial = MatrixUser(userId = sessionId))
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvent(event: SignedOutEvent) {
|
||||
when (event) {
|
||||
SignedOutEvent.SignInAgain -> coroutineScope.launch {
|
||||
SignedOutEvent.Submit -> coroutineScope.launch {
|
||||
sessionStore.removeSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
@@ -53,8 +57,15 @@ class SignedOutPresenter(
|
||||
|
||||
return SignedOutState(
|
||||
appName = buildMeta.applicationName,
|
||||
signedOutSession = signedOutSession,
|
||||
signedOutMatrixUser = signedOutSession,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SessionData.toMatrixUser() = MatrixUser(
|
||||
userId = sessionId,
|
||||
displayName = userDisplayName,
|
||||
avatarUrl = userAvatarUrl,
|
||||
avatarThumbnail = userAvatarData,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,10 +8,10 @@
|
||||
|
||||
package io.element.android.features.signedout.impl
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class SignedOutState(
|
||||
val appName: String,
|
||||
val signedOutSession: SessionData?,
|
||||
val signedOutMatrixUser: MatrixUser,
|
||||
val eventSink: (SignedOutEvent) -> Unit,
|
||||
)
|
||||
|
||||
+9
-31
@@ -9,44 +9,22 @@
|
||||
package io.element.android.features.signedout.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
|
||||
open class SignedOutStatePreviewParam : PreviewParameterProvider<SignedOutState> {
|
||||
override val values: Sequence<SignedOutState>
|
||||
get() = sequenceOf(
|
||||
aSignedOutState(),
|
||||
// Add other states here
|
||||
aSignedOutState(signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aSignedOutState() = SignedOutState(
|
||||
internal fun aSignedOutState(
|
||||
signedOutSession: MatrixUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
|
||||
eventSink: (SignedOutEvent) -> Unit = {},
|
||||
) = SignedOutState(
|
||||
appName = "AppName",
|
||||
signedOutSession = aSessionData(),
|
||||
eventSink = {},
|
||||
signedOutMatrixUser = signedOutSession,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
private fun aSessionData(
|
||||
sessionId: String = "@alice:server.org",
|
||||
isTokenValid: Boolean = false,
|
||||
): SessionData {
|
||||
return SessionData(
|
||||
userId = sessionId,
|
||||
deviceId = "aDeviceId",
|
||||
accessToken = "anAccessToken",
|
||||
refreshToken = "aRefreshToken",
|
||||
homeserverUrl = "aHomeserverUrl",
|
||||
oAuthData = null,
|
||||
loginTimestamp = null,
|
||||
isTokenValid = isTokenValid,
|
||||
loginType = LoginType.UNKNOWN,
|
||||
passphrase = null,
|
||||
sessionPath = "/a/path/to/a/session",
|
||||
cachePath = "/a/path/to/a/cache",
|
||||
position = 0,
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
userAvatarData = null,
|
||||
)
|
||||
}
|
||||
|
||||
+56
-29
@@ -9,7 +9,9 @@
|
||||
package io.element.android.features.signedout.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -31,25 +33,32 @@ import io.element.android.libraries.designsystem.components.visuallist.VisualLis
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
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.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=795-6095
|
||||
*/
|
||||
@Composable
|
||||
fun SignedOutView(
|
||||
state: SignedOutState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(onBack = { state.eventSink(SignedOutEvent.SignInAgain) })
|
||||
BackHandler(onBack = { state.eventSink(SignedOutEvent.Submit) })
|
||||
HeaderFooterPage(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
header = { SignedOutHeader(state) },
|
||||
content = { SignedOutContent() },
|
||||
content = { SignedOutContent(state.signedOutMatrixUser) },
|
||||
footer = {
|
||||
SignedOutFooter(
|
||||
onSignInAgain = { state.eventSink(SignedOutEvent.SignInAgain) },
|
||||
onSubmit = { state.eventSink(SignedOutEvent.Submit) },
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -61,46 +70,64 @@ private fun SignedOutHeader(state: SignedOutState) {
|
||||
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
|
||||
title = stringResource(id = R.string.screen_signed_out_title),
|
||||
subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
|
||||
iconStyle = BigIcon.Style.Default(
|
||||
CompoundIcons.UserProfileSolid(),
|
||||
usePrimaryTint = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignedOutContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = BiasAlignment(
|
||||
horizontalBias = 0f,
|
||||
verticalBias = -0.4f
|
||||
)
|
||||
private fun SignedOutContent(
|
||||
signedOutMatrixUser: MatrixUser,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
VisualList(
|
||||
items = persistentListOf(
|
||||
VisualListItemData(
|
||||
message = stringResource(id = R.string.screen_signed_out_reason_1),
|
||||
iconVector = CompoundIcons.Lock(),
|
||||
),
|
||||
VisualListItemData(
|
||||
message = stringResource(id = R.string.screen_signed_out_reason_2),
|
||||
iconVector = CompoundIcons.Devices(),
|
||||
),
|
||||
VisualListItemData(
|
||||
message = stringResource(id = R.string.screen_signed_out_reason_3),
|
||||
iconVector = CompoundIcons.Block(),
|
||||
),
|
||||
),
|
||||
AccountInfoCard(
|
||||
AccountInfoCardState(
|
||||
matrixUser = signedOutMatrixUser,
|
||||
mode = AccountInfoCardMode.Simple,
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = BiasAlignment(
|
||||
horizontalBias = 0f,
|
||||
verticalBias = -0.4f
|
||||
)
|
||||
) {
|
||||
VisualList(
|
||||
items = persistentListOf(
|
||||
VisualListItemData(
|
||||
message = stringResource(id = R.string.screen_signed_out_reason_1),
|
||||
iconVector = CompoundIcons.Lock(),
|
||||
),
|
||||
VisualListItemData(
|
||||
message = stringResource(id = R.string.screen_signed_out_reason_2),
|
||||
iconVector = CompoundIcons.Devices(),
|
||||
),
|
||||
VisualListItemData(
|
||||
message = stringResource(id = R.string.screen_signed_out_reason_3),
|
||||
iconVector = CompoundIcons.Block(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignedOutFooter(
|
||||
onSignInAgain: () -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
ButtonColumnMolecule {
|
||||
Button(
|
||||
text = stringResource(id = CommonStrings.action_sign_in_again),
|
||||
onClick = onSignInAgain,
|
||||
text = stringResource(id = CommonStrings.action_ok),
|
||||
onClick = onSubmit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
+28
-7
@@ -13,6 +13,7 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
@@ -20,6 +21,8 @@ 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.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -30,7 +33,12 @@ class SignedOutPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val aSessionData = aSessionData()
|
||||
val aSessionData = aSessionData(
|
||||
sessionId = A_SESSION_ID.value,
|
||||
userDisplayName = "Alice",
|
||||
userAvatarUrl = "avatarUrl",
|
||||
userAvatarData = "avatarData",
|
||||
)
|
||||
val sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(aSessionData)
|
||||
)
|
||||
@@ -38,16 +46,27 @@ class SignedOutPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME)
|
||||
assertThat(initialState.signedOutSession).isEqualTo(aSessionData)
|
||||
assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID))
|
||||
assertThat(awaitItem().signedOutMatrixUser).isEqualTo(
|
||||
MatrixUser(
|
||||
userId = A_SESSION_ID,
|
||||
displayName = "Alice",
|
||||
avatarUrl = "avatarUrl",
|
||||
avatarThumbnail = "avatarData",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - sign in again`() = runTest {
|
||||
val aSessionData = aSessionData()
|
||||
val aSessionData = aSessionData(
|
||||
sessionId = A_SESSION_ID.value,
|
||||
userDisplayName = "Alice",
|
||||
)
|
||||
val sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(aSessionData)
|
||||
)
|
||||
@@ -57,13 +76,15 @@ class SignedOutPresenterTest {
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.signedOutSession).isEqualTo(aSessionData)
|
||||
assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID, displayName = "Alice"))
|
||||
assertThat(sessionStore.getAllSessions()).isNotEmpty()
|
||||
assertThat(sessionStore.numberOfSessions()).isEqualTo(1)
|
||||
initialState.eventSink(SignedOutEvent.SignInAgain)
|
||||
assertThat(awaitItem().signedOutSession).isNull()
|
||||
initialState.eventSink(SignedOutEvent.Submit)
|
||||
runCurrent()
|
||||
assertThat(sessionStore.getAllSessions()).isEmpty()
|
||||
assertThat(sessionStore.numberOfSessions()).isEqualTo(0)
|
||||
// The removal of the session must not change the rendered data
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.signedout.impl
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
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 io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
class SignedOutViewTest : RobolectricTest() {
|
||||
@Test
|
||||
@Config(qualifiers = "h1024dp")
|
||||
fun `the signed out user is displayed`() = runAndroidComposeUiTest {
|
||||
setSignedOutView(
|
||||
aSignedOutState(
|
||||
signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
),
|
||||
)
|
||||
onNodeWithText("Alice").assertIsDisplayed()
|
||||
onNodeWithText("@alice:server.org").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1024dp")
|
||||
fun `the signed out user id is displayed when there is no display name`() = runAndroidComposeUiTest {
|
||||
setSignedOutView(
|
||||
aSignedOutState(
|
||||
signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null),
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
),
|
||||
)
|
||||
onNodeWithText("@alice:server.org").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on OK sends a Submit event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<SignedOutEvent>()
|
||||
setSignedOutView(
|
||||
aSignedOutState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(SignedOutEvent.Submit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing back sends a Submit event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<SignedOutEvent>()
|
||||
setSignedOutView(
|
||||
aSignedOutState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
pressBackKey()
|
||||
eventsRecorder.assertSingle(SignedOutEvent.Submit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setSignedOutView(
|
||||
state: SignedOutState,
|
||||
) {
|
||||
setContent {
|
||||
SignedOutView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user