From bfdc628dd2accfb7427c53867daf2ed4805ec0e2 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 26 Jun 2026 15:05:13 -0400 Subject: [PATCH] Fix skip button during PIN entry in regV5. --- .../screens/pincreation/PinCreationScreen.kt | 28 ++++++++++++- .../screens/pincreation/PinCreationState.kt | 3 +- .../pincreation/PinCreationViewModel.kt | 4 +- .../PinEntryForSvrRestoreViewModel.kt | 8 ++-- .../screens/pinentry/PinEntryScreen.kt | 41 +++++++++++++++++-- .../src/main/res/values/strings.xml | 8 ++++ .../PinEntryForSvrRestoreViewModelTest.kt | 9 ++-- 7 files changed, 83 insertions(+), 18 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 2bcf73d7df..92f8c1a9a6 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 @@ -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 = {} + ) + } +} 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 c2e49f55a2..238f7949a8 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 @@ -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()})" } } 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 be4c7947f9..8b9b0c65b9 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 @@ -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) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt index ead0e0a555..4a1bdd6b8d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt @@ -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( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt index a37f760885..c8b9d8e333 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt @@ -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)) + } } } } diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 1fa42e73f0..b9fee67a5a 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -365,6 +365,14 @@ Skip Continue + + Skip PIN entry? + + 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. + + Create New PIN + + Cancel Allow Notifications diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt index de13176734..7c8de18395 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt @@ -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() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() } // ==================== ToggleKeyboard Tests ====================