Merge pull request #5981 from element-hq/feature/bma/iterateOnVerificationScreen

Iterate on verification screen
This commit is contained in:
Benoit Marty
2026-01-09 10:24:11 +01:00
committed by GitHub
36 changed files with 184 additions and 249 deletions

View File

@@ -21,10 +21,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.focused
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -45,7 +43,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton
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.designsystem.theme.components.InvisibleButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -73,11 +70,7 @@ fun IncomingVerificationView(
TopAppBar(
title = {},
navigationIcon = {
when {
step is Step.Initial && !step.isWaiting -> Unit
step is Step.Completed -> Unit
else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) })
}
BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) })
},
colors = topAppBarColors(containerColor = Color.Transparent),
)
@@ -103,19 +96,11 @@ fun IncomingVerificationView(
private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
val iconStyle = when (step) {
Step.Canceled -> BigIcon.Style.AlertSolid
is Step.Initial -> if (step.isWaiting) {
BigIcon.Style.Loading
} else {
when (request) {
is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.LockSolid())
is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
}
}
is Step.Verifying -> if (step.isWaiting) {
BigIcon.Style.Loading
} else {
BigIcon.Style.Default(CompoundIcons.ReactionSolid())
is Step.Initial -> when (request) {
is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.Devices())
is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
}
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.ReactionSolid())
Step.Completed -> BigIcon.Style.SuccessSolid
Step.Failure -> BigIcon.Style.AlertSolid
}
@@ -159,10 +144,6 @@ private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.
.semantics(mergeDescendants = true) {
contentDescription = timeLimitMessage
focused = true
if (iconStyle == BigIcon.Style.Loading) {
// Same code than Modifier.progressSemantics()
progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate
}
}
.focusable(),
iconStyle = iconStyle,
@@ -185,7 +166,7 @@ private fun IncomingVerificationContent(
@Composable
private fun ContentInitial(
initialIncoming: Step.Initial,
stepInitial: Step.Initial,
request: VerificationRequest.Incoming,
) {
when (request) {
@@ -195,9 +176,9 @@ private fun ContentInitial(
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SessionDetailsView(
deviceName = initialIncoming.deviceDisplayName,
deviceId = initialIncoming.deviceId,
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
deviceName = stepInitial.deviceDisplayName,
deviceId = stepInitial.deviceId,
signInFormattedTimestamp = stepInitial.formattedSignInTime,
)
Text(
modifier = Modifier
@@ -227,48 +208,42 @@ private fun ContentInitial(
private fun IncomingVerificationBottomMenu(
state: IncomingVerificationState,
) {
val step = state.step
val eventSink = state.eventSink
when (step) {
when (val step = state.step) {
is Step.Initial -> {
if (step.isWaiting) {
// Show nothing
} else {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_verification),
onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ignore),
onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) },
)
}
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_verification),
enabled = !step.isWaiting,
showProgress = step.isWaiting,
onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ignore),
enabled = !step.isWaiting,
onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) },
)
}
}
is Step.Verifying -> {
if (step.isWaiting) {
// Add invisible buttons to keep the same screen layout
VerificationBottomMenu {
InvisibleButton()
InvisibleButton()
}
} else {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) },
)
}
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
enabled = !step.isWaiting,
showProgress = step.isWaiting,
onClick = {
eventSink(IncomingVerificationViewEvents.ConfirmVerification)
},
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
enabled = !step.isWaiting,
onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) },
)
}
}
Step.Canceled,
@@ -278,7 +253,9 @@ private fun IncomingVerificationBottomMenu(
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_done),
onClick = { eventSink(IncomingVerificationViewEvents.GoBack) },
onClick = {
eventSink(IncomingVerificationViewEvents.GoBack)
},
)
}
}

View File

@@ -24,10 +24,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.focused
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -37,7 +35,6 @@ import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
@@ -96,20 +93,18 @@ fun OutgoingVerificationView(
topBar = {
TopAppBar(
title = {},
navigationIcon = if (step != Step.Completed) {
{ BackButton(onClick = ::cancelOrResetFlow) }
} else {
{}
navigationIcon = {
BackButton(onClick = ::cancelOrResetFlow)
},
colors = topAppBarColors(containerColor = Color.Transparent)
colors = topAppBarColors(containerColor = Color.Transparent),
)
},
header = {
OutgoingVerificationHeader(step = step, request = state.request)
},
footer = {
OutgoingVerificationViewBottomMenu(
screenState = state,
OutgoingVerificationBottomMenu(
state = state,
onCancelClick = ::cancelOrResetFlow,
onContinueClick = onFinish,
)
@@ -117,7 +112,7 @@ fun OutgoingVerificationView(
isScrollable = true,
) {
OutgoingVerificationContent(
flowState = step,
step = step,
request = state.request,
onLearnMoreClick = onLearnMoreClick,
)
@@ -129,20 +124,16 @@ fun OutgoingVerificationView(
private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest.Outgoing) {
val iconStyle = when (step) {
Step.Loading -> error("Should not happen")
Step.AwaitingOtherDeviceResponse,
Step.Initial -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> BigIcon.Style.Default(CompoundIcons.Devices())
is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.LockSolid())
is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
}
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Loading
Step.Canceled -> BigIcon.Style.AlertSolid
Step.Ready -> BigIcon.Style.Default(CompoundIcons.ReactionSolid())
Step.Completed -> BigIcon.Style.SuccessSolid
is Step.Verifying -> {
if (step.state is AsyncData.Loading<Unit>) {
BigIcon.Style.Loading
} else {
BigIcon.Style.Default(CompoundIcons.ReactionSolid())
}
BigIcon.Style.Default(CompoundIcons.ReactionSolid())
}
is Step.Exit -> return
}
@@ -201,10 +192,6 @@ private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest.
.semantics(mergeDescendants = true) {
contentDescription = timeLimitMessage
focused = true
if (iconStyle == BigIcon.Style.Loading) {
// Same code than Modifier.progressSemantics()
progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate
}
}
.focusable(),
iconStyle = iconStyle,
@@ -215,20 +202,16 @@ private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest.
@Composable
private fun OutgoingVerificationContent(
flowState: Step,
step: Step,
request: VerificationRequest.Outgoing,
onLearnMoreClick: () -> Unit,
) {
when (flowState) {
is Step.Initial -> {
when (request) {
is VerificationRequest.Outgoing.CurrentSession -> Unit
is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick)
}
}
is Step.Verifying -> {
VerificationContentVerifying(flowState.data)
when (step) {
is Step.Initial -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> Unit
is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick)
}
is Step.Verifying -> VerificationContentVerifying(step.data)
else -> Unit
}
}
@@ -252,23 +235,23 @@ private fun ContentInitial(
}
@Composable
private fun OutgoingVerificationViewBottomMenu(
screenState: OutgoingVerificationState,
private fun OutgoingVerificationBottomMenu(
state: OutgoingVerificationState,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
) {
val verificationViewState = screenState.step
val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading<Unit>
when (verificationViewState) {
val eventSink = state.eventSink
when (val step = state.step) {
Step.Loading -> error("Should not happen")
is Step.AwaitingOtherDeviceResponse,
is Step.Initial -> {
VerificationBottomMenu {
val isWaiting = step is Step.AwaitingOtherDeviceResponse
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_verification),
enabled = !isWaiting,
showProgress = isWaiting,
onClick = { eventSink(OutgoingVerificationViewEvents.RequestVerification) },
)
InvisibleButton()
@@ -298,30 +281,26 @@ private fun OutgoingVerificationViewBottomMenu(
)
}
}
is Step.AwaitingOtherDeviceResponse -> Unit
is Step.Verifying -> {
if (isVerifying) {
// Add invisible buttons to keep the same screen layout
VerificationBottomMenu {
InvisibleButton()
InvisibleButton()
}
} else {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
onClick = {
eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
},
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
onClick = { eventSink(OutgoingVerificationViewEvents.DeclineVerification) },
)
}
val isVerifying = step.state.isLoading()
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
enabled = !isVerifying,
showProgress = isVerifying,
onClick = {
eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
},
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
enabled = !isVerifying,
onClick = {
eventSink(OutgoingVerificationViewEvents.DeclineVerification)
},
)
}
}
is Step.Completed -> {
@@ -334,7 +313,7 @@ private fun OutgoingVerificationViewBottomMenu(
InvisibleButton()
}
}
is Step.Exit -> return
is Step.Exit -> Unit
}
}

View File

@@ -34,7 +34,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.Icon
import io.element.android.libraries.ui.strings.CommonStrings
@@ -82,11 +81,6 @@ object BigIcon {
* A success style with a tinted background.
*/
data object SuccessSolid : Style
/**
* A loading style with the default background color.
*/
data object Loading : Style
}
/**
@@ -110,7 +104,6 @@ object BigIcon {
Style.Success -> Color.Transparent
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
Style.Loading -> ElementTheme.colors.bgSubtleSecondary
}
Box(
modifier = modifier
@@ -119,52 +112,39 @@ object BigIcon {
.background(backgroundColor),
contentAlignment = Alignment.Center,
) {
if (style is Style.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(27.dp),
color = ElementTheme.colors.iconSecondary,
trackColor = Color.Transparent,
strokeWidth = 3.dp,
)
} else {
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
Style.Loading -> error("This should never be reached")
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
Style.Loading -> error("This should never be reached")
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else if (style.usePrimaryTint) {
ElementTheme.colors.iconPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
Style.Loading -> error("This should never be reached")
}
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else if (style.usePrimaryTint) {
ElementTheme.colors.iconPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
}
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
}
}
}
@@ -199,6 +179,5 @@ internal class BigIconStyleProvider : PreviewParameterProvider<BigIcon.Style> {
BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
BigIcon.Style.Success,
BigIcon.Style.SuccessSolid,
BigIcon.Style.Loading,
)
}