Iterate on login error: add a cancel button that fully close the flow.

tom
This commit is contained in:
Benoit Marty 2026-01-28 16:09:31 +01:00 committed by Benoit Marty
parent 692302df5c
commit 36d5500a65
10 changed files with 120 additions and 28 deletions

View file

@ -193,7 +193,8 @@ class LinkNewDeviceFlowNode(
is ErrorType.Unknown -> ErrorScreenType.UnknownError is ErrorType.Unknown -> ErrorScreenType.UnknownError
is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
} }
// It is OK to push on backstack, since when user leaves the error screen, a new root will be set // It is OK to push on backstack, since when user leaves the error screen, a new root will be set,
// or the whole flow will be popped.
backstack.push(NavTarget.Error(error)) backstack.push(NavTarget.Error(error))
} }
@ -263,6 +264,12 @@ class LinkNewDeviceFlowNode(
linkNewDesktopHandler.reset() linkNewDesktopHandler.reset()
backstack.newRoot(NavTarget.Root) backstack.newRoot(NavTarget.Root)
} }
override fun onCancel() {
linkNewMobileHandler.reset()
linkNewDesktopHandler.reset()
callback.onDone()
}
} }
createNode<ErrorNode>(buildContext, listOf(callback, navTarget.errorScreenType)) createNode<ErrorNode>(buildContext, listOf(callback, navTarget.errorScreenType))
} }

View file

@ -27,6 +27,7 @@ class ErrorNode(
) : Node(buildContext = buildContext, plugins = plugins) { ) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onRetry() fun onRetry()
fun onCancel()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -38,6 +39,7 @@ class ErrorNode(
modifier = modifier, modifier = modifier,
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
onRetry = callback::onRetry, onRetry = callback::onRetry,
onCancel = callback::onCancel,
) )
} }
} }

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -41,17 +42,23 @@ import kotlinx.collections.immutable.persistentListOf
fun ErrorView( fun ErrorView(
errorScreenType: ErrorScreenType, errorScreenType: ErrorScreenType,
onRetry: () -> Unit, onRetry: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val appName = LocalBuildMeta.current.applicationName val appName = LocalBuildMeta.current.applicationName
BackHandler(onBack = onRetry) BackHandler(onBack = onCancel)
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid, iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName), title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName), subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) }, content = { Content(errorScreenType) },
buttons = { Buttons(onRetry) }, buttons = {
Buttons(
onRetry = onRetry,
onCancel = onCancel,
)
},
) )
} }
@ -118,11 +125,19 @@ private fun Content(errorScreenType: ErrorScreenType) {
} }
@Composable @Composable
private fun Buttons(onRetry: () -> Unit) { private fun Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_over), text = stringResource(CommonStrings.action_try_again),
onClick = onRetry onClick = onRetry,
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
) )
} }
@ -133,6 +148,7 @@ internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class)
ErrorView( ErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
onRetry = {}, onRetry = {},
onCancel = {},
) )
} }
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
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.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
@ -26,33 +27,45 @@ class ErrorViewTest {
val rule = createAndroidComposeRule<ComponentActivity>() val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onRetry callback`() { fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( rule.setErrorView(
onRetry = callback onCancel = callback,
) )
rule.pressBackKey() rule.pressBackKey()
} }
} }
@Test @Test
fun `on start over button clicked - calls the expected callback`() { fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( rule.setErrorView(
onRetry = callback onRetry = callback
) )
rule.clickOn(CommonStrings.action_start_over) rule.clickOn(CommonStrings.action_try_again)
}
}
@Test
fun `on cancel button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setErrorView(
onCancel = callback
)
rule.clickOn(CommonStrings.action_cancel)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView(
onRetry: () -> Unit, onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError, errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
) { ) {
setContent { setContent {
ErrorView( ErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
onRetry = onRetry, onRetry = onRetry,
onCancel = onCancel,
) )
} }
} }

View file

@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
@ -196,7 +197,12 @@ class LoginFlowNode(
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback)) createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
} }
NavTarget.QrCode -> { NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext) val callback = object : QrCodeLoginFlowNode.Callback {
override fun navigateBack() {
backstack.pop()
}
}
createNode<QrCodeLoginFlowNode>(buildContext, listOf(callback))
} }
is NavTarget.ConfirmAccountProvider -> { is NavTarget.ConfirmAccountProvider -> {
val inputs = ConfirmAccountProviderNode.Inputs( val inputs = ConfirmAccountProviderNode.Inputs(

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
@ -64,6 +65,12 @@ class QrCodeLoginFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun navigateBack()
}
private val callback: Callback = callback()
private var authenticationJob: Job? = null private var authenticationJob: Job? = null
override val graph = qrCodeLoginGraphFactory.create() override val graph = qrCodeLoginGraphFactory.create()
@ -85,7 +92,6 @@ class QrCodeLoginFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
observeLoginStep() observeLoginStep()
} }
@ -178,7 +184,13 @@ class QrCodeLoginFlowNode(
} }
is NavTarget.Error -> { is NavTarget.Error -> {
val callback = object : QrCodeErrorNode.Callback { val callback = object : QrCodeErrorNode.Callback {
override fun onRetry() = reset() override fun onRetry() {
reset()
}
override fun onCancel() {
callback.navigateBack()
}
} }
createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback)) createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback))
} }

View file

@ -31,6 +31,7 @@ class QrCodeErrorNode(
) : Node(buildContext = buildContext, plugins = plugins) { ) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onRetry() fun onRetry()
fun onCancel()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -43,6 +44,7 @@ class QrCodeErrorNode(
errorScreenType = qrCodeErrorScreenType, errorScreenType = qrCodeErrorScreenType,
appName = buildMeta.productionApplicationName, appName = buildMeta.productionApplicationName,
onRetry = callback::onRetry, onRetry = callback::onRetry,
onCancel = callback::onCancel,
) )
} }
} }

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -44,16 +45,22 @@ fun QrCodeErrorView(
errorScreenType: QrCodeErrorScreenType, errorScreenType: QrCodeErrorScreenType,
appName: String, appName: String,
onRetry: () -> Unit, onRetry: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler(onBack = onRetry) BackHandler(onBack = onCancel)
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid, iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName), title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName), subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) }, content = { Content(errorScreenType) },
buttons = { Buttons(onRetry) }, buttons = {
Buttons(
onRetry = onRetry,
onCancel = onCancel,
)
},
) )
} }
@ -118,11 +125,19 @@ private fun Content(errorScreenType: QrCodeErrorScreenType) {
} }
@Composable @Composable
private fun Buttons(onRetry: () -> Unit) { private fun Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_qr_code_login_start_over_button), text = stringResource(CommonStrings.action_try_again),
onClick = onRetry onClick = onRetry,
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
) )
} }
@ -133,7 +148,8 @@ internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvi
QrCodeErrorView( QrCodeErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
appName = "Element X", appName = "Element X",
onRetry = {} onRetry = {},
onCancel = {},
) )
} }
} }

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -183,7 +184,11 @@ class QrCodeLoginFlowNodeTest {
) )
return QrCodeLoginFlowNode( return QrCodeLoginFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = emptyList(), plugins = listOf(
object : QrCodeLoginFlowNode.Callback {
override fun navigateBack() = lambdaError()
}
),
qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager), qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager),
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )

View file

@ -12,8 +12,9 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
@ -28,10 +29,10 @@ class QrCodeErrorViewTest {
val rule = createAndroidComposeRule<ComponentActivity>() val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onRetry callback`() { fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( rule.setQrCodeErrorView(
onRetry = callback onCancel = callback,
) )
rule.pressBackKey() rule.pressBackKey()
} }
@ -41,14 +42,25 @@ class QrCodeErrorViewTest {
fun `on try again button clicked - calls the expected callback`() { fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( rule.setQrCodeErrorView(
onRetry = callback onRetry = callback,
) )
rule.clickOn(R.string.screen_qr_code_login_start_over_button) rule.clickOn(CommonStrings.action_try_again)
}
}
@Test
fun `on cancel button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeErrorView(
onCancel = callback,
)
rule.clickOn(CommonStrings.action_cancel)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeErrorView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeErrorView(
onRetry: () -> Unit, onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError, errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError,
appName: String = "Element X", appName: String = "Element X",
) { ) {
@ -56,7 +68,8 @@ class QrCodeErrorViewTest {
QrCodeErrorView( QrCodeErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
appName = appName, appName = appName,
onRetry = onRetry onRetry = onRetry,
onCancel = onCancel,
) )
} }
} }