From c55f281213840d5edea354dc38eff6a96b45e65c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 18 Jun 2026 16:58:38 -0400 Subject: [PATCH] Improve pin creation UX in regV5. --- .../screens/pincreation/PinCreationScreen.kt | 225 ++++++++++++------ .../pincreation/PinCreationScreenEvents.kt | 1 + .../screens/pincreation/PinCreationState.kt | 4 +- .../pincreation/PinCreationViewModel.kt | 25 +- .../src/main/res/values/strings.xml | 2 + .../pincreation/PinCreationViewModelTest.kt | 74 +++++- 6 files changed, 245 insertions(+), 86 deletions(-) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt index b136129db9..2bcf73d7df 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt @@ -5,6 +5,14 @@ package org.signal.registration.screens.pincreation +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,6 +37,8 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,9 +74,11 @@ import org.signal.registration.screens.TwoPaneRegistrationScaffold import org.signal.registration.screens.attachDebugLogHelper import org.signal.core.ui.R as CoreR +private const val STEP_TRANSITION_DURATION = 250 + /** * PIN creation screen for the registration flow. - * Allows users to create a new PIN for their account. + * Allows users to create a new PIN for their account, then confirm it. */ @Composable fun PinCreationScreen( @@ -74,18 +86,19 @@ fun PinCreationScreen( onEvent: (PinCreationScreenEvents) -> Unit, modifier: Modifier = Modifier ) { - var pin by rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } - val canSubmitPin = pin.length >= 4 + val activePin = remember { mutableStateOf("") } + val canSubmitPin = activePin.value.length >= 4 + + BackHandler(enabled = state.isConfirmEnabled) { + onEvent(PinCreationScreenEvents.BackToPinEntry) + } when (val params = RegistrationScaffold.rememberLayoutParams()) { is RegistrationScaffold.Params.OnePane -> OnePaneLayout( params = params, state = state, - pin = pin, + activePin = activePin, canSubmitPin = canSubmitPin, - focusRequester = focusRequester, - onPinChanged = { pin = it }, onEvent = onEvent, modifier = modifier ) @@ -93,19 +106,12 @@ fun PinCreationScreen( is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout( params = params, state = state, - pin = pin, + activePin = activePin, canSubmitPin = canSubmitPin, - focusRequester = focusRequester, - onPinChanged = { pin = it }, onEvent = onEvent, modifier = modifier ) } - - // autofocus PIN field on initial composition - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } } @OptIn(ExperimentalMaterial3Api::class) @@ -113,10 +119,8 @@ fun PinCreationScreen( private fun OnePaneLayout( params: RegistrationScaffold.Params.OnePane, state: PinCreationState, - pin: String, + activePin: MutableState, canSubmitPin: Boolean, - focusRequester: FocusRequester, - onPinChanged: (String) -> Unit, onEvent: (PinCreationScreenEvents) -> Unit, modifier: Modifier = Modifier ) { @@ -139,31 +143,23 @@ private fun OnePaneLayout( .verticalScroll(scrollState) .padding(params.panePadding(hasHeader = true)) ) { - PinDescription( - isConfirmEnabled = state.isConfirmEnabled, - onLearnMore = { onEvent(PinCreationScreenEvents.LearnMore) } - ) + PinStepTransition(isConfirmEnabled = state.isConfirmEnabled) { isConfirm -> + Column { + PinDescription( + isConfirmEnabled = isConfirm, + onLearnMore = { onEvent(PinCreationScreenEvents.LearnMore) } + ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - PinInputField( - state = state, - pin = pin, - canSubmitPin = canSubmitPin, - focusRequester = focusRequester, - onPinChanged = onPinChanged, - onSubmit = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - PinInputLabel(state) - Spacer(modifier = Modifier.height(16.dp)) - - KeyboardToggleButton( - state = state, - onToggleKeyboard = { onEvent(PinCreationScreenEvents.ToggleKeyboard) } - ) + PinInputSection( + state = state, + isConfirm = isConfirm, + activePin = activePin, + onEvent = onEvent + ) + } + } } }, footer = { @@ -171,7 +167,7 @@ private fun OnePaneLayout( params = params, canSubmitPin = canSubmitPin, isElevated = scrollState.canScrollForward, - onNext = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) } + onNext = { onEvent(PinCreationScreenEvents.PinSubmitted(activePin.value)) } ) } ) @@ -182,10 +178,8 @@ private fun OnePaneLayout( private fun TwoPaneLayout( params: RegistrationScaffold.Params.TwoPane, state: PinCreationState, - pin: String, + activePin: MutableState, canSubmitPin: Boolean, - focusRequester: FocusRequester, - onPinChanged: (String) -> Unit, onEvent: (PinCreationScreenEvents) -> Unit, modifier: Modifier = Modifier ) { @@ -211,10 +205,12 @@ private fun TwoPaneLayout( .verticalScroll(firstPaneScrollState) .padding(paddingValues) ) { - PinDescription( - isConfirmEnabled = state.isConfirmEnabled, - onLearnMore = { onEvent(PinCreationScreenEvents.LearnMore) } - ) + PinStepTransition(isConfirmEnabled = state.isConfirmEnabled) { isConfirm -> + PinDescription( + isConfirmEnabled = isConfirm, + onLearnMore = { onEvent(PinCreationScreenEvents.LearnMore) } + ) + } } }, secondPane = { paddingValues -> @@ -226,23 +222,14 @@ private fun TwoPaneLayout( .verticalScroll(secondPaneScrollState) .padding(paddingValues) ) { - PinInputField( - state = state, - pin = pin, - canSubmitPin = canSubmitPin, - focusRequester = focusRequester, - onPinChanged = onPinChanged, - onSubmit = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - PinInputLabel(state) - Spacer(modifier = Modifier.height(16.dp)) - KeyboardToggleButton( - state = state, - onToggleKeyboard = { onEvent(PinCreationScreenEvents.ToggleKeyboard) } - ) + PinStepTransition(isConfirmEnabled = state.isConfirmEnabled) { isConfirm -> + PinInputSection( + state = state, + isConfirm = isConfirm, + activePin = activePin, + onEvent = onEvent + ) + } } }, footer = { @@ -250,12 +237,40 @@ private fun TwoPaneLayout( params = params, canSubmitPin = canSubmitPin, isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, - onNext = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) } + onNext = { onEvent(PinCreationScreenEvents.PinSubmitted(activePin.value)) } ) } ) } +/** + * Animates between the create and confirm steps with a horizontal slide, as if they were separate screens. + * Moving forward to the confirm step slides in from the right; returning to the create step slides in from the left. + */ +@Composable +private fun PinStepTransition( + isConfirmEnabled: Boolean, + modifier: Modifier = Modifier, + content: @Composable (isConfirm: Boolean) -> Unit +) { + AnimatedContent( + targetState = isConfirmEnabled, + transitionSpec = { + if (targetState) { + (slideInHorizontally(animationSpec = tween(STEP_TRANSITION_DURATION)) { it } + fadeIn(tween(STEP_TRANSITION_DURATION))) togetherWith + (slideOutHorizontally(animationSpec = tween(STEP_TRANSITION_DURATION)) { -it } + fadeOut(tween(STEP_TRANSITION_DURATION))) + } else { + (slideInHorizontally(animationSpec = tween(STEP_TRANSITION_DURATION)) { -it } + fadeIn(tween(STEP_TRANSITION_DURATION))) togetherWith + (slideOutHorizontally(animationSpec = tween(STEP_TRANSITION_DURATION)) { it } + fadeOut(tween(STEP_TRANSITION_DURATION))) + } + }, + label = "PinCreationStep", + modifier = modifier + ) { isConfirm -> + content(isConfirm) + } +} + @Composable private fun PinDescription( isConfirmEnabled: Boolean, @@ -314,8 +329,54 @@ private fun PinDescription( } @Composable -private fun PinInputField( +private fun PinInputSection( state: PinCreationState, + isConfirm: Boolean, + activePin: MutableState, + onEvent: (PinCreationScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + var pin by remember { mutableStateOf("") } + val canSubmitPin = pin.length >= 4 + val focusRequester = remember { FocusRequester() } + + // Keep the footer's submit action in sync with the step currently on screen. + if (isConfirm == state.isConfirmEnabled) { + SideEffect { activePin.value = pin } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column(modifier = modifier) { + PinInputField( + isAlphanumericKeyboard = state.isAlphanumericKeyboard, + pin = pin, + canSubmitPin = canSubmitPin, + focusRequester = focusRequester, + onPinChanged = { pin = it }, + onSubmit = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + PinInputLabel( + isConfirm = isConfirm, + isAlphanumericKeyboard = state.isAlphanumericKeyboard, + isMismatch = state.pinMismatch + ) + Spacer(modifier = Modifier.height(16.dp)) + KeyboardToggleButton( + isAlphanumericKeyboard = state.isAlphanumericKeyboard, + onToggleKeyboard = { onEvent(PinCreationScreenEvents.ToggleKeyboard) } + ) + } +} + +@Composable +private fun PinInputField( + isAlphanumericKeyboard: Boolean, pin: String, canSubmitPin: Boolean, focusRequester: FocusRequester, @@ -330,7 +391,7 @@ private fun PinInputField( textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), singleLine = true, keyboardOptions = KeyboardOptions( - keyboardType = if (state.isAlphanumericKeyboard) KeyboardType.Text else KeyboardType.Number, + keyboardType = if (isAlphanumericKeyboard) KeyboardType.Text else KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions(onDone = { if (canSubmitPin) onSubmit() }), @@ -340,25 +401,28 @@ private fun PinInputField( @Composable private fun PinInputLabel( - state: PinCreationState, + isConfirm: Boolean, + isAlphanumericKeyboard: Boolean, + isMismatch: Boolean, modifier: Modifier = Modifier ) { Text( text = when { - state.isConfirmEnabled -> stringResource(R.string.PinCreationScreen__reenter_pin) - state.isAlphanumericKeyboard -> stringResource(R.string.PinCreationScreen__pin_at_least_4_characters) + isConfirm -> stringResource(R.string.PinCreationScreen__reenter_pin) + isMismatch -> stringResource(R.string.PinCreationScreen__pins_dont_match) + isAlphanumericKeyboard -> stringResource(R.string.PinCreationScreen__pin_at_least_4_characters) else -> stringResource(R.string.PinCreationScreen__pin_at_least_4_digits) }, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (!isConfirm && isMismatch) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, modifier = modifier.fillMaxWidth() ) } @Composable private fun KeyboardToggleButton( - state: PinCreationState, + isAlphanumericKeyboard: Boolean, onToggleKeyboard: () -> Unit, modifier: Modifier = Modifier ) { @@ -372,7 +436,7 @@ private fun KeyboardToggleButton( modifier = Modifier.padding(end = 8.dp) ) Text( - text = if (state.isAlphanumericKeyboard) { + text = if (isAlphanumericKeyboard) { stringResource(R.string.PinCreationScreen__switch_to_numeric) } else { stringResource(R.string.PinCreationScreen__switch_to_alphanumeric) @@ -511,3 +575,14 @@ private fun PinCreationScreenConfirmPreview() { ) } } + +@AllDevicePreviews +@Composable +private fun PinCreationScreenMismatchPreview() { + Previews.Preview { + PinCreationScreen( + state = PinCreationState(pinMismatch = true), + onEvent = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt index ae42f12daf..49b36472ad 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt @@ -12,4 +12,5 @@ sealed class PinCreationScreenEvents { data object ToggleKeyboard : PinCreationScreenEvents() data object LearnMore : PinCreationScreenEvents() data object OptOut : PinCreationScreenEvents() + data object BackToPinEntry : PinCreationScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt index 899bb84848..c2e49f55a2 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt @@ -11,9 +11,11 @@ import org.signal.core.util.censor data class PinCreationState( val isAlphanumericKeyboard: Boolean = false, val isConfirmEnabled: Boolean = false, + val pinMismatch: Boolean = false, + val firstPin: String? = null, val accountEntropyPool: AccountEntropyPool? = null ) { override fun toString(): String { - return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()})" + return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()})" } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt index 9c760178af..218c058d5a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt @@ -54,9 +54,24 @@ class PinCreationViewModel( suspend fun applyEvent(state: PinCreationState, event: PinCreationScreenEvents) { when (event) { is PinCreationScreenEvents.PinSubmitted -> { - _state.value = state.copy(isConfirmEnabled = false) - val result = applyPinSubmitted(state, event.pin) - _state.value = result + when { + !state.isConfirmEnabled -> { + Log.d(TAG, "[PinSubmitted] First PIN entered. Asking the user to confirm it.") + _state.value = state.copy(firstPin = event.pin, isConfirmEnabled = true, pinMismatch = false) + } + + event.pin != state.firstPin -> { + Log.w(TAG, "[PinSubmitted] Confirmation PIN did not match. Returning to PIN creation.") + _state.value = state.copy(isConfirmEnabled = false, firstPin = null, pinMismatch = true) + } + + else -> { + Log.d(TAG, "[PinSubmitted] Confirmation PIN matched.") + _state.value = state.copy(pinMismatch = false) + val result = applyPinSubmitted(state, event.pin) + _state.value = result + } + } } is PinCreationScreenEvents.ToggleKeyboard -> { @@ -68,6 +83,10 @@ class PinCreationViewModel( is PinCreationScreenEvents.LearnMore -> { // Handled by the navigation layer, which opens the help URL directly. } + + is PinCreationScreenEvents.BackToPinEntry -> { + _state.value = state.copy(isConfirmEnabled = false, firstPin = null, pinMismatch = false) + } is PinCreationScreenEvents.OptOut -> { _state.value = state.copy(isConfirmEnabled = false) applyOptOut() diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 834f09581f..dc0cd04272 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -333,6 +333,8 @@ PIN must be at least 4 characters Re-enter PIN + + PINs don\'t match. Try again. Learn more about PINs diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt index 4a2e3b2d76..320b0daf32 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt @@ -8,13 +8,17 @@ package org.signal.registration.screens.pincreation import assertk.assertThat import assertk.assertions.hasSize import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import assertk.assertions.isNull +import assertk.assertions.isTrue import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -61,17 +65,73 @@ class PinCreationViewModelTest { Dispatchers.resetMain() } + private fun TestScope.collectStates(): List { + val states = mutableListOf() + backgroundScope.launch(testDispatcher) { viewModel.state.collect { states.add(it) } } + return states + } + + // ==================== PIN Confirmation Tests ==================== + + @Test + fun `first PinSubmitted enters confirm mode and does not back up`() = runTest(testDispatcher) { + val states = collectStates() + val initialState = PinCreationState(accountEntropyPool = AccountEntropyPool.generate()) + + viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + + coVerify(exactly = 0) { mockRepository.setNewlyCreatedPin(any(), any(), any()) } + assertThat(emittedParentEvents).hasSize(0) + assertThat(states.last().isConfirmEnabled).isTrue() + assertThat(states.last().pinMismatch).isFalse() + } + + @Test + fun `mismatched confirmation PIN returns to creation with error and does not back up`() = runTest(testDispatcher) { + val states = collectStates() + val confirmState = PinCreationState( + accountEntropyPool = AccountEntropyPool.generate(), + isConfirmEnabled = true, + firstPin = "123456" + ) + + viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("999999")) + + coVerify(exactly = 0) { mockRepository.setNewlyCreatedPin(any(), any(), any()) } + assertThat(emittedParentEvents).hasSize(0) + assertThat(states.last().isConfirmEnabled).isFalse() + assertThat(states.last().pinMismatch).isTrue() + assertThat(states.last().firstPin).isNull() + } + + @Test + fun `BackToPinEntry returns to creation step and clears the first PIN`() = runTest(testDispatcher) { + val states = collectStates() + val confirmState = PinCreationState( + accountEntropyPool = AccountEntropyPool.generate(), + isConfirmEnabled = true, + firstPin = "123456", + pinMismatch = true + ) + + viewModel.applyEvent(confirmState, PinCreationScreenEvents.BackToPinEntry) + + assertThat(states.last().isConfirmEnabled).isFalse() + assertThat(states.last().firstPin).isNull() + assertThat(states.last().pinMismatch).isFalse() + } + // ==================== PinSubmitted Success Tests ==================== @Test - fun `PinSubmitted with valid AEP and successful SVR backup hands off to finishRegistrationOrCreateProfile`() = runTest(testDispatcher) { + fun `matching confirmation PIN with valid AEP and successful SVR backup hands off to finishRegistrationOrCreateProfile`() = runTest(testDispatcher) { val aep = AccountEntropyPool.generate() - val initialState = PinCreationState(accountEntropyPool = aep) + val confirmState = PinCreationState(accountEntropyPool = aep, isConfirmEnabled = true, firstPin = "123456") coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any()) } returns RequestResult.Success(null) - viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456")) coVerify { mockRepository.setRestoreDecision(RestoreDecision.NEW_ACCOUNT) } coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) } @@ -81,9 +141,9 @@ class PinCreationViewModelTest { @Test fun `PinSubmitted with null AEP emits ResetState`() = runTest(testDispatcher) { - val initialState = PinCreationState(accountEntropyPool = null) + val confirmState = PinCreationState(accountEntropyPool = null, isConfirmEnabled = true, firstPin = "123456") - viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456")) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) @@ -94,12 +154,12 @@ class PinCreationViewModelTest { @Test fun `PinSubmitted with NotRegistered error emits ResetState`() = runTest(testDispatcher) { val aep = AccountEntropyPool.generate() - val initialState = PinCreationState(accountEntropyPool = aep) + val confirmState = PinCreationState(accountEntropyPool = aep, isConfirmEnabled = true, firstPin = "123456") coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any()) } returns RequestResult.NonSuccess(NetworkController.BackupMasterKeyError.NotRegistered) - viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456")) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)