From d31bfbd0e709f7d029bc4dc700ebc41fd4383ecb Mon Sep 17 00:00:00 2001 From: null Date: Fri, 21 Aug 2026 00:19:35 -0500 Subject: [PATCH] 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) --- .../period/feature/lock/LockScreen.kt | 6 ++ .../feature/onboarding/OnboardingScreen.kt | 12 ++++ .../period/feature/lock/ForgotPinBackTest.kt | 65 +++++++++++++++++++ docs/design/README.md | 9 +++ 4 files changed, 92 insertions(+) create mode 100644 app/src/test/kotlin/dev/privacyllc/period/feature/lock/ForgotPinBackTest.kt diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt index ea46025..31d5296 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -81,6 +82,11 @@ fun LockScreen(viewModel: AppLockViewModel) { var erasing by remember { mutableStateOf(false) } 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 }) return } diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt index b9c13ca..fc72c75 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -75,6 +76,17 @@ fun OnboardingScreen( ) { 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()) { LinearProgressIndicator( progress = { (Step.entries.indexOf(state.step) + 1f) / Step.entries.size }, diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/ForgotPinBackTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/ForgotPinBackTest.kt new file mode 100644 index 0000000..52019c5 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/ForgotPinBackTest.kt @@ -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() + + @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()) + } +} diff --git a/docs/design/README.md b/docs/design/README.md index 87d42ca..9da8f72 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 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 notification button is chosen twice: once as words on a lock screen, and once