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