Fix skip button during PIN entry in regV5.

This commit is contained in:
Greyson Parrelli
2026-06-26 15:05:13 -04:00
committed by Michelle Tang
parent 80aa521e42
commit bfdc628dd2
7 changed files with 83 additions and 18 deletions
@@ -22,12 +22,14 @@ import androidx.compose.foundation.layout.fillMaxSize
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.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -167,6 +169,7 @@ private fun OnePaneLayout(
params = params,
canSubmitPin = canSubmitPin,
isElevated = scrollState.canScrollForward,
loading = state.loading,
onNext = { onEvent(PinCreationScreenEvents.PinSubmitted(activePin.value)) }
)
}
@@ -237,6 +240,7 @@ private fun TwoPaneLayout(
params = params,
canSubmitPin = canSubmitPin,
isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward,
loading = state.loading,
onNext = { onEvent(PinCreationScreenEvents.PinSubmitted(activePin.value)) }
)
}
@@ -515,6 +519,7 @@ private fun NextButton(
params: RegistrationScaffold.Params,
canSubmitPin: Boolean,
isElevated: Boolean,
loading: Boolean,
onNext: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -528,12 +533,20 @@ private fun NextButton(
) {
Buttons.LargeTonal(
onClick = onNext,
enabled = canSubmitPin,
enabled = canSubmitPin && !loading,
modifier = Modifier
.widthIn(max = params.maxButtonWidth)
.padding(params.footerPadding)
) {
Text(stringResource(R.string.PinCreationScreen__next))
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(stringResource(R.string.PinCreationScreen__next))
}
}
}
}
@@ -586,3 +599,14 @@ private fun PinCreationScreenMismatchPreview() {
)
}
}
@AllDevicePreviews
@Composable
private fun PinCreationScreenLoadingPreview() {
Previews.Preview {
PinCreationScreen(
state = PinCreationState(isConfirmEnabled = true, loading = true),
onEvent = {}
)
}
}
@@ -12,10 +12,11 @@ data class PinCreationState(
val isAlphanumericKeyboard: Boolean = false,
val isConfirmEnabled: Boolean = false,
val pinMismatch: Boolean = false,
val loading: Boolean = false,
val firstPin: String? = null,
val accountEntropyPool: AccountEntropyPool? = null
) {
override fun toString(): String {
return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()})"
return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, loading=$loading, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()})"
}
}
@@ -67,9 +67,9 @@ class PinCreationViewModel(
else -> {
Log.d(TAG, "[PinSubmitted] Confirmation PIN matched.")
_state.value = state.copy(pinMismatch = false)
_state.value = state.copy(pinMismatch = false, loading = true)
val result = applyPinSubmitted(state, event.pin)
_state.value = result
_state.value = result.copy(loading = false)
}
}
}
@@ -145,11 +145,9 @@ class PinEntryForSvrRestoreViewModel(
}
}
private suspend fun handleSkip() {
Log.i(TAG, "[Skip] User opted out of restoring data and creating a PIN. Recording choice and completing registration.")
repository.setPinOptedOut()
repository.setRestoreDecision(RestoreDecision.SKIPPED)
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
private fun handleSkip() {
Log.i(TAG, "[Skip] User opted to skip restoring their PIN. Navigating to PIN creation.")
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
}
class Factory(
@@ -15,11 +15,13 @@ import androidx.compose.foundation.layout.fillMaxSize
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.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -46,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.registration.R
@@ -64,6 +67,7 @@ fun PinEntryScreen(
modifier: Modifier = Modifier
) {
var pin by rememberSaveable { mutableStateOf("") }
var showSkipDialog by rememberSaveable { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val canSubmitPin = pin.isNotEmpty()
@@ -75,6 +79,7 @@ fun PinEntryScreen(
canSubmitPin = canSubmitPin,
focusRequester = focusRequester,
onPinChanged = { pin = it },
onSkip = { showSkipDialog = true },
onEvent = onEvent,
modifier = modifier
)
@@ -86,11 +91,26 @@ fun PinEntryScreen(
canSubmitPin = canSubmitPin,
focusRequester = focusRequester,
onPinChanged = { pin = it },
onSkip = { showSkipDialog = true },
onEvent = onEvent,
modifier = modifier
)
}
if (showSkipDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.PinEntryScreen__skip_pin_entry),
body = stringResource(R.string.PinEntryScreen__skip_pin_entry_message),
confirm = stringResource(R.string.PinEntryScreen__create_new_pin),
dismiss = stringResource(R.string.PinEntryScreen__cancel),
onConfirm = {
showSkipDialog = false
onEvent(PinEntryScreenEvents.Skip)
},
onDismiss = { showSkipDialog = false }
)
}
// autofocus PIN field on initial composition
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@@ -105,6 +125,7 @@ private fun OnePaneLayout(
canSubmitPin: Boolean,
focusRequester: FocusRequester,
onPinChanged: (String) -> Unit,
onSkip: () -> Unit,
onEvent: (PinEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
@@ -147,7 +168,7 @@ private fun OnePaneLayout(
if (state.mode != PinEntryState.Mode.RegistrationLock) {
SkipButton(
onSkip = { onEvent(PinEntryScreenEvents.Skip) },
onSkip = onSkip,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(params.edgeInset)
@@ -160,6 +181,7 @@ private fun OnePaneLayout(
params = params,
canSubmitPin = canSubmitPin,
isElevated = scrollState.canScrollForward,
loading = state.loading,
onContinue = { onEvent(PinEntryScreenEvents.PinEntered(pin)) }
)
}
@@ -174,6 +196,7 @@ private fun TwoPaneLayout(
canSubmitPin: Boolean,
focusRequester: FocusRequester,
onPinChanged: (String) -> Unit,
onSkip: () -> Unit,
onEvent: (PinEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
@@ -226,7 +249,7 @@ private fun TwoPaneLayout(
if (state.mode != PinEntryState.Mode.RegistrationLock) {
SkipButton(
onSkip = { onEvent(PinEntryScreenEvents.Skip) },
onSkip = onSkip,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(params.edgeInset)
@@ -239,6 +262,7 @@ private fun TwoPaneLayout(
params = params,
canSubmitPin = canSubmitPin,
isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward,
loading = state.loading,
onContinue = { onEvent(PinEntryScreenEvents.PinEntered(pin)) }
)
}
@@ -366,6 +390,7 @@ private fun ContinueButton(
params: RegistrationScaffold.Params,
canSubmitPin: Boolean,
isElevated: Boolean,
loading: Boolean,
onContinue: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -379,12 +404,20 @@ private fun ContinueButton(
) {
Buttons.LargeTonal(
onClick = onContinue,
enabled = canSubmitPin,
enabled = canSubmitPin && !loading,
modifier = Modifier
.widthIn(max = params.maxButtonWidth)
.padding(params.footerPadding)
) {
Text(stringResource(R.string.PinEntryScreen__continue))
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(stringResource(R.string.PinEntryScreen__continue))
}
}
}
}
@@ -365,6 +365,14 @@
<string name="PinEntryScreen__skip">Skip</string>
<!-- Labels the button to submit the PIN. -->
<string name="PinEntryScreen__continue">Continue</string>
<!-- Title of the dialog shown when the user taps skip on the PIN entry screen. -->
<string name="PinEntryScreen__skip_pin_entry">Skip PIN entry?</string>
<!-- Body of the dialog shown when the user taps skip on the PIN entry screen, explaining the consequences of creating a new PIN. -->
<string name="PinEntryScreen__skip_pin_entry_message">If you can\'t remember your PIN, you can create a new one. You can register and use your account but you\'ll lose some saved settings like your profile information.</string>
<!-- Labels the button in the skip dialog that takes the user to create a new PIN. -->
<string name="PinEntryScreen__create_new_pin">Create New PIN</string>
<!-- Labels the button in the skip dialog that dismisses it. -->
<string name="PinEntryScreen__cancel">Cancel</string>
<!-- Title for the screen asking the user to grant the notification permission -->
<string name="AllowNotificationsScreen__allow_notifications">Allow Notifications</string>
@@ -227,15 +227,16 @@ class PinEntryForSvrRestoreViewModelTest {
// ==================== Skip Tests ====================
@Test
fun `Skip records PIN opt-out and completes registration`() = runTest {
fun `Skip navigates to PinCreate`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.SvrRestore)
viewModel.applyEvent(initialState, PinEntryScreenEvents.Skip, parentEventEmitter, stateEmitter)
coVerify { mockRepository.setPinOptedOut() }
coVerify { mockRepository.setRestoreDecision(RestoreDecision.SKIPPED) }
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
assertThat(emittedParentEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinCreate>()
}
// ==================== ToggleKeyboard Tests ====================