diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index 54baee6663..f69abc944b 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -27,6 +27,7 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.compound.theme.ElementTheme import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.features.linknewdevice.impl.screens.confirmation.CodeConfirmationNode import io.element.android.features.linknewdevice.impl.screens.desktop.DesktopNoticeNode import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType @@ -107,6 +108,11 @@ class LinkNewDeviceFlowNode( val data: String, ) : NavTarget + @Parcelize + data class CodeConfirmation( + val code: String, + ) : NavTarget + @Parcelize data object MobileEnterNumber : NavTarget @@ -166,7 +172,9 @@ class LinkNewDeviceFlowNode( is LinkDesktopStep.Error -> { navigateToError(linkDesktopStep.errorType) } - is LinkDesktopStep.EstablishingSecureChannel -> Unit + is LinkDesktopStep.EstablishingSecureChannel -> { + backstack.push(NavTarget.CodeConfirmation(linkDesktopStep.checkCodeString)) + } is LinkDesktopStep.InvalidQrCode -> { // This error will be handled by the ScanQrCodeNode } @@ -250,6 +258,18 @@ class LinkNewDeviceFlowNode( } createNode(buildContext, listOf(callback)) } + is NavTarget.CodeConfirmation -> { + val callback = object : CodeConfirmationNode.Callback { + override fun onCancel() { + // Push error + backstack.push(NavTarget.Error(ErrorScreenType.Cancelled)) + } + } + val inputs = CodeConfirmationNode.Inputs( + code = navTarget.code, + ) + createNode(buildContext, listOf(inputs, callback)) + } is NavTarget.MobileShowQrCode -> { val callback = object : ShowQrCodeNode.Callback { override fun navigateBack() { diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationNode.kt new file mode 100644 index 0000000000..a8db4d2d75 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationNode.kt @@ -0,0 +1,47 @@ +/* + * 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.linknewdevice.impl.screens.confirmation + +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.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class CodeConfirmationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onCancel() + } + + data class Inputs( + val code: String, + ) : NodeInputs + + private val callback: Callback = callback() + private val input = inputs() + + @Composable + override fun View(modifier: Modifier) { + CodeConfirmationView( + code = input.code, + onCancel = callback::onCancel, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationView.kt new file mode 100644 index 0000000000..d981574f86 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationView.kt @@ -0,0 +1,134 @@ +/* + * 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.linknewdevice.impl.screens.confirmation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.size +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.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun CodeConfirmationView( + code: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onCancel) + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(R.string.screen_qr_code_login_device_code_title), + subTitle = stringResource(R.string.screen_qr_code_login_device_code_subtitle), + content = { Content(code = code) }, + buttons = { Buttons(onCancel = onCancel) } + ) +} + +@Composable +private fun Content(code: String) { + Column( + modifier = Modifier.padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Digits(code = code) + Spacer(modifier = Modifier.height(32.dp)) + WaitingForOtherDevice() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Digits(code: String) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + code.forEach { + Text( + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(ElementTheme.colors.bgActionSecondaryPressed) + .padding(horizontal = 16.dp, vertical = 17.dp), + text = it.toString() + ) + } + } +} + +@Composable +private fun WaitingForOtherDevice() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .padding(2.dp), + strokeWidth = 2.dp, + ) + Text( + text = stringResource(R.string.screen_qr_code_login_verify_code_loading), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun Buttons( + onCancel: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_cancel), + onClick = onCancel, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun CodeConfirmationViewPreview() { + ElementPreview { + CodeConfirmationView( + code = "67", + onCancel = {}, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml index 321b168751..6ffcce227a 100644 --- a/features/linknewdevice/impl/src/main/res/values/localazy.xml +++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml @@ -34,6 +34,8 @@ "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi" "If that doesn’t work, sign in manually" "Connection not secure" + "You’ll be asked to enter the two digits shown on this device." + "Enter the number below on your other device" "The sign in was cancelled on the other device." "Sign in request cancelled" "The sign in was declined on the other device." @@ -54,4 +56,5 @@ Try signing in manually, or scan the QR code with another device." "You need to give permission for %1$s to use your device’s camera in order to continue." "Allow camera access to scan the QR code" "An unexpected error occurred. Please try again." + "Waiting for your other device" diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.confirmation_CodeConfirmationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.confirmation_CodeConfirmationView_Day_0_en.png new file mode 100644 index 0000000000..a9e31653f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.confirmation_CodeConfirmationView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8dab1dc964cea9a76dc7130d8ac2bfe5d3c866fd6d8d969101eb50e828775d3 +size 31915 diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.confirmation_CodeConfirmationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.confirmation_CodeConfirmationView_Night_0_en.png new file mode 100644 index 0000000000..43c472ebb1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.confirmation_CodeConfirmationView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec67e3fef25c57331cb2425f8fc46a733e16a98c9945d0a4e5b950843c42fa34 +size 31087 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index b38268e5f7..03ada90c4b 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -163,7 +163,9 @@ "screen_qr_code_login_connection_note_secure_state.*", "screen_qr_code_login_unknown_error_description", "screen_qr_code_login_invalid_scan_state_.*", - "screen_qr_code_login_no_camera_permission_state_.*" + "screen_qr_code_login_no_camera_permission_state_.*", + "screen_qr_code_login_device_code_.*", + "screen_qr_code_login_verify_code_loading" ] }, {