Arm and retire the onboarding wizard from stored state (#163)

Arming hung off the in-app grant callback, so a fresh install that granted
on Android's settings screen and came back to a restarted process never
got the wizard. It now arms on any launch holding the permission, before
the grant reaches the plan, and clears again on the closing screen so a
later re-grant is only the permission step.

Also: retire the visibility notice once read, keep the backup step when
the calendar read fails, and collect the view choice for every step.
This commit is contained in:
2026-08-10 16:49:08 +02:00
parent f8e22d8774
commit 350393c352
7 changed files with 156 additions and 43 deletions

View File

@@ -556,10 +556,11 @@ class SettingsPrefs @Inject constructor(
}
/**
* Whether this install went through the calendar grant in-app, i.e. is a
* fresh one that owes the wizard's optional steps (#163). Set at the grant
* and only for an install that had not finished the reminder step, so an
* existing user who revokes and re-grants the permission isn't re-onboarded.
* Whether this install is a fresh one still owing the wizard's optional
* steps (#163). Armed on any launch that holds the calendar permission and
* has not finished the reminder step, and cleared again by
* [finishOnboardingWizard], so an existing user who revokes and re-grants
* the permission isn't re-onboarded.
*/
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
@@ -620,6 +621,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[ONBOARDING_DONE_KEY] = shown }
}
/**
* Close the wizard for good: the closing screen has been seen and the
* install stops counting as fresh, so a later revoke-and-re-grant of the
* calendar permission is only ever the permission screen. One edit, so the
* plan can never observe a half-closed wizard.
*/
suspend fun finishOnboardingWizard() {
store.edit { prefs ->
prefs[ONBOARDING_DONE_KEY] = true
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
}
}
/**
* The default reminder lead times (minutes before start) prefilled on new
* **timed** events. The empty list = no default reminder — the prior

View File

@@ -90,8 +90,10 @@ fun RootScreen(
val onboarding: OnboardingViewModel = hiltViewModel()
val plan by onboarding.plan.collectAsStateWithLifecycle()
// Runs however the permission was granted, including via Android's
// app-settings screen (caught by the ON_RESUME above).
// The grant is reported however it was taken, including via Android's
// app-settings screen (caught by the ON_RESUME above) — arming the wizard
// hangs off that, not off the callback. Only a grant made during this
// session owes the re-scan.
LaunchedEffect(hasPermission) {
onboarding.setHasPermission(hasPermission)
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()

View File

@@ -55,11 +55,14 @@ data class OnboardingPlan(
/**
* Work out the flow from what is stored.
*
* The optional steps are only for installs that went through the grant in-app —
* an existing user sees nothing new. Before the grant that can only be guessed,
* so an install that has not answered the reminder step either is treated as
* fresh, which is the same condition [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
* commits to storage a moment later.
* The optional steps are only for installs that are still working through their
* first run — an existing user sees nothing new. Before the grant that can only
* be guessed, so an install that has not answered the reminder step either is
* treated as fresh, which is the same condition
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
* commits to storage a moment later. The flag is cleared again on the closing
* screen, so revoking and re-granting the permission years later is only ever
* the permission step.
*
* [backupApplies] is null until the calendar list can be read, i.e. for the
* whole permission step. The backup step is assumed to apply until proven

View File

@@ -25,6 +25,11 @@ fun OnboardingSteps(
onPermissionGranted: () -> Unit,
modifier: Modifier = Modifier,
) {
// Collected for every step, not just the two that draw a preview: starting
// it when the picker composes would leave the step blank for the frames the
// first stored emission takes to arrive — right as it slides in.
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
// Coerced because the outgoing half of a transition may be a step the live
// plan has since dropped — the backup step goes once the calendars say it
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
@@ -78,31 +83,25 @@ fun OnboardingSteps(
)
// The choice is null only until DataStore's first emission; rendering
// nothing for that frame beats a preview built on the wrong defaults.
OnboardingStep.View -> {
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
choice?.let {
ViewStep(
choice = it,
onSelect = viewModel::setDefaultView,
onFinished = viewModel::finishView,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
OnboardingStep.View -> choice?.let {
ViewStep(
choice = it,
onSelect = viewModel::setDefaultView,
onFinished = viewModel::finishView,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
OnboardingStep.MonthStyle -> {
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
choice?.let {
MonthStyleStep(
choice = it,
onSelect = viewModel::setMonthViewStyle,
onFinished = viewModel::finishMonthStyle,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
OnboardingStep.MonthStyle -> choice?.let {
MonthStyleStep(
choice = it,
onSelect = viewModel::setMonthViewStyle,
onFinished = viewModel::finishMonthStyle,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
OnboardingStep.Visibility -> VisibilityStep(
onFinished = viewModel::finishVisibility,

View File

@@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -55,20 +56,39 @@ class OnboardingViewModel @Inject constructor(
) : ViewModel() {
/** Null until the host reports it — assuming either way would flash a screen. */
private val hasPermission = MutableStateFlow<Boolean?>(null)
private val permissionReported = MutableStateFlow<Boolean?>(null)
/**
* The grant as the rest of the flow sees it: published only once the wizard
* has been armed for it. Arming is a stored write, so announcing the grant
* first would leave a few frames in which the install looks neither fresh
* nor permission-less and the plan collapses to its one-step form.
*/
private val hasPermission: StateFlow<Boolean?> = permissionReported
.mapLatest { granted ->
if (granted == true) armWizardIfFresh()
granted
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
/**
* Whether the backup step applies: something of yours is worth exporting and
* nothing you write to is synced anywhere. Null while the calendar list
* cannot be read, which is every frame before the grant.
* cannot be read, which is every frame before the grant — and any frame the
* provider read fails, since an empty list is also how a null cursor
* arrives. Guessing false there would drop the step for the whole session.
*/
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
repository.calendars()
.catch { emit(emptyList()) }
.map { calendars -> calendars.backupApplies() }
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
.catch { emit(null) }
.flowOn(io)
}
}
@@ -125,7 +145,18 @@ class OnboardingViewModel @Inject constructor(
)
fun setHasPermission(granted: Boolean) {
hasPermission.value = granted
permissionReported.value = granted
}
/**
* Arm the wizard for an install that has yet to answer the reminder step,
* whatever route the grant took. Gating this on the in-app grant would
* strand anyone who granted on Android's own settings screen and came back
* to a restarted process: they hold the permission at launch, so nothing
* would ever arm and the flow would silently shrink to the reminder step.
*/
private suspend fun armWizardIfFresh() {
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
}
/**
@@ -134,7 +165,6 @@ class OnboardingViewModel @Inject constructor(
* daily worker.
*/
fun onPermissionGranted() {
viewModelScope.launch { prefs.armOnboardingWizard() }
scanner.scanInBackground()
}
@@ -220,12 +250,32 @@ class OnboardingViewModel @Inject constructor(
/** Acknowledge the calendar-visibility notice (#75). */
fun finishVisibility() {
viewModelScope.launch { prefs.setOnboardingVisibilityDone() }
viewModelScope.launch {
prefs.setOnboardingVisibilityDone()
// Retiring the notice drops the step from the plan, so it waits
// until nothing follows it — otherwise the count would fall from
// under the user on the way to the closing screen.
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
}
}
/** Close the wizard from its last screen. */
fun finishOnboarding() {
viewModelScope.launch { prefs.setOnboardingDoneShown() }
viewModelScope.launch {
prefs.finishOnboardingWizard()
retireVisibilityNotice()
}
}
/**
* Put the one-time visibility notice away for good. Nothing else clears it
* now that the wizard has taken the notice over, and left standing it would
* keep its step in every later plan — padding the counter on a re-grant.
*/
private suspend fun retireVisibilityNotice() {
if (calendarPrefs.visibilityNoticePending.first() == true) {
calendarPrefs.setVisibilityNoticePending(false)
}
}
/**

View File

@@ -337,6 +337,32 @@ class SettingsPrefsTest {
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
}
@Test
fun `the wizard arms on a first run`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.armOnboardingWizard()
assertThat(prefs.onboardingWizardArmed.first()).isTrue()
}
@Test
fun `the wizard does not arm once the reminder step is answered`(@TempDir tempDir: Path) = runTest {
// An existing install re-granting the permission is not re-onboarded.
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setReminderOnboardingDone()
prefs.armOnboardingWizard()
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
}
@Test
fun `closing the wizard disarms it`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.armOnboardingWizard()
prefs.finishOnboardingWizard()
assertThat(prefs.onboardingDoneShown.first()).isTrue()
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
}
@Test
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -103,6 +103,25 @@ class OnboardingPlanTest {
assertThat(revoked.showsProgress).isFalse()
}
@Test
fun `re-granting after the wizard itself ran does not re-enter it`() {
// The closing screen clears the armed flag, so someone the wizard did
// onboard is in the same position as anyone else who revokes the
// permission: one screen, no counter.
val revoked = plan(remindersDone = true, wizardArmed = false, doneShown = true)
assertThat(revoked.steps).containsExactly(OnboardingStep.Permission)
assertThat(revoked.showsProgress).isFalse()
}
@Test
fun `a read notice leaves nothing behind in a later plan`() {
// The notice is retired once read, so its step stops padding the count
// on a later re-grant.
val later = plan(remindersDone = true, visibilityArmed = false, visibilityDone = true)
assertThat(later.steps).containsExactly(OnboardingStep.Permission)
assertThat(later.showsProgress).isFalse()
}
@Test
fun `answering every step ends the flow`() {
val done = plan(