fix: step back through onboarding and the erase confirmation

The two screens outside the navigation graph kept the old behaviour: the
system gesture left them entirely while the on-screen control beside it
stepped back.

Onboarding has a Back button and the gesture ignored it, so back from the
middle of onboarding exited the whole flow -- the app's first impression
of what its own controls mean. It now steps, and deliberately does not
intercept on the first step: there, back means leaving the app, which is
its ordinary meaning everywhere else. Silently skipping onboarding would
not be.

"Forgot your PIN?" is not a destination -- the lock screen swaps it in on
a remembered flag -- so the gesture left the lock screen from the one
place where the only other control on screen erases everything. It returns
to the PIN entry now, like its Cancel always did.

closes #67

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-21 00:19:35 -05:00
parent b81a4fb43b
commit d31bfbd0e7
4 changed files with 92 additions and 0 deletions

View File

@ -18,6 +18,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -81,6 +82,11 @@ fun LockScreen(viewModel: AppLockViewModel) {
var erasing by remember { mutableStateOf(false) } var erasing by remember { mutableStateOf(false) }
if (erasing) { if (erasing) {
// The gesture and the on-screen Cancel do the same thing. Without this,
// back from the erase confirmation left the lock screen entirely — from
// the one screen in the app where an accidental exit is least welcome,
// and where the only other control is the one that erases everything.
BackHandler { erasing = false }
ForgotPinScreen(onCancel = { erasing = false }) ForgotPinScreen(onCancel = { erasing = false })
return return
} }

View File

@ -26,6 +26,7 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.SelectableDates import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -75,6 +76,17 @@ fun OnboardingScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
// The gesture steps back through onboarding, exactly as the on-screen Back
// does. It used to leave onboarding altogether from the middle of it, which
// is the app's first impression of what its controls mean.
//
// Deliberately not enabled on the first step: there, back means leaving the
// app, which is the ordinary meaning everywhere else. Silently skipping
// onboarding would not be.
BackHandler(enabled = state.step != Step.WELCOME && state.step != Step.FIRST_FORECAST) {
viewModel.back()
}
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { (Step.entries.indexOf(state.step) + 1f) / Step.entries.size }, progress = { (Step.entries.indexOf(state.step) + 1f) / Step.entries.size },

View File

@ -0,0 +1,65 @@
package dev.privacyllc.period.feature.lock
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import dev.privacyllc.period.designsystem.PeriodTheme
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Back, on the screen where an accidental exit is least welcome.
*
* "Forgot your PIN?" is not a navigation destination the lock screen swaps it
* in on a remembered flag so the system gesture left the lock screen entirely
* from the erase confirmation, while the Cancel button beside it stepped back.
* The only other control on that screen erases everything.
*
* This asserts the shape rather than the screen itself: `ForgotPinScreen` needs a
* `LockEraseViewModel` from Hilt, and what is worth pinning is that the gesture
* is intercepted while the erase step is showing and released once it is not.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ForgotPinBackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun `the gesture steps back out of the erase screen instead of leaving the lock`() {
var erasing by mutableStateOf(true)
rule.setContent {
PeriodTheme {
if (erasing) {
BackHandler { erasing = false }
Text("erase confirmation")
} else {
Text("enter your PIN")
}
}
}
// While the erase step is up, the gesture belongs to it.
assertTrue(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks())
rule.runOnUiThread { rule.activity.onBackPressedDispatcher.onBackPressed() }
rule.waitForIdle()
rule.onNodeWithText("enter your PIN").assertIsDisplayed()
// And once it is gone, nothing of ours is registered — the lock screen
// itself deliberately swallows nothing, because there is nowhere behind
// it to go.
assertFalse(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks())
}
}

View File

@ -238,6 +238,15 @@ The same shape applies anywhere a screen has steps: the arrow, the system
gesture and the on-screen Cancel all mean the same thing at the same step, and gesture and the on-screen Cancel all mean the same thing at the same step, and
none of them may skip a question that has not been answered. none of them may skip a question that has not been answered.
That now includes the two screens outside the navigation graph. Onboarding steps
back through itself rather than exiting from the middle — and deliberately does
*not* intercept the gesture on the first step, where back means leaving the app,
which is its ordinary meaning everywhere else; silently skipping onboarding would
not be. The "Forgot your PIN?" confirmation returns to the PIN entry, which
matters more than it looks: it is not a destination, so the gesture used to leave
the lock screen entirely from the one screen where the only other control erases
everything.
### A button says what it does, and it does what it says ### A button says what it does, and it does what it says
A notification button is chosen twice: once as words on a lock screen, and once A notification button is chosen twice: once as words on a lock screen, and once