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:
@@ -556,10 +556,11 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this install went through the calendar grant in-app, i.e. is a
|
* Whether this install is a fresh one still owing the wizard's optional
|
||||||
* fresh one that owes the wizard's optional steps (#163). Set at the grant
|
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||||
* and only for an install that had not finished the reminder step, so an
|
* has not finished the reminder step, and cleared again by
|
||||||
* existing user who revokes and re-grants the permission isn't re-onboarded.
|
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||||
|
* the permission isn't re-onboarded.
|
||||||
*/
|
*/
|
||||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||||
@@ -620,6 +621,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
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
|
* The default reminder lead times (minutes before start) prefilled on new
|
||||||
* **timed** events. The empty list = no default reminder — the prior
|
* **timed** events. The empty list = no default reminder — the prior
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ fun RootScreen(
|
|||||||
|
|
||||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||||
// Runs however the permission was granted, including via Android's
|
// The grant is reported however it was taken, including via Android's
|
||||||
// app-settings screen (caught by the ON_RESUME above).
|
// 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) {
|
LaunchedEffect(hasPermission) {
|
||||||
onboarding.setHasPermission(hasPermission)
|
onboarding.setHasPermission(hasPermission)
|
||||||
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||||
|
|||||||
@@ -55,11 +55,14 @@ data class OnboardingPlan(
|
|||||||
/**
|
/**
|
||||||
* Work out the flow from what is stored.
|
* Work out the flow from what is stored.
|
||||||
*
|
*
|
||||||
* The optional steps are only for installs that went through the grant in-app —
|
* The optional steps are only for installs that are still working through their
|
||||||
* an existing user sees nothing new. Before the grant that can only be guessed,
|
* first run — an existing user sees nothing new. Before the grant that can only
|
||||||
* so an install that has not answered the reminder step either is treated as
|
* be guessed, so an install that has not answered the reminder step either is
|
||||||
* fresh, which is the same condition [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
* treated as fresh, which is the same condition
|
||||||
* commits to storage a moment later.
|
* [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
|
* [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
|
* whole permission step. The backup step is assumed to apply until proven
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ fun OnboardingSteps(
|
|||||||
onPermissionGranted: () -> Unit,
|
onPermissionGranted: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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
|
// 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
|
// 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.
|
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
|
||||||
@@ -78,9 +83,7 @@ fun OnboardingSteps(
|
|||||||
)
|
)
|
||||||
// The choice is null only until DataStore's first emission; rendering
|
// The choice is null only until DataStore's first emission; rendering
|
||||||
// nothing for that frame beats a preview built on the wrong defaults.
|
// nothing for that frame beats a preview built on the wrong defaults.
|
||||||
OnboardingStep.View -> {
|
OnboardingStep.View -> choice?.let {
|
||||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
|
||||||
choice?.let {
|
|
||||||
ViewStep(
|
ViewStep(
|
||||||
choice = it,
|
choice = it,
|
||||||
onSelect = viewModel::setDefaultView,
|
onSelect = viewModel::setDefaultView,
|
||||||
@@ -90,10 +93,7 @@ fun OnboardingSteps(
|
|||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
OnboardingStep.MonthStyle -> choice?.let {
|
||||||
OnboardingStep.MonthStyle -> {
|
|
||||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
|
||||||
choice?.let {
|
|
||||||
MonthStyleStep(
|
MonthStyleStep(
|
||||||
choice = it,
|
choice = it,
|
||||||
onSelect = viewModel::setMonthViewStyle,
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
@@ -103,7 +103,6 @@ fun OnboardingSteps(
|
|||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
OnboardingStep.Visibility -> VisibilityStep(
|
OnboardingStep.Visibility -> VisibilityStep(
|
||||||
onFinished = viewModel::finishVisibility,
|
onFinished = viewModel::finishVisibility,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -55,20 +56,39 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/** Null until the host reports it — assuming either way would flash a screen. */
|
/** 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
|
* Whether the backup step applies: something of yours is worth exporting and
|
||||||
* nothing you write to is synced anywhere. Null while the calendar list
|
* 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 ->
|
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||||
if (granted != true) {
|
if (granted != true) {
|
||||||
flowOf(null)
|
flowOf(null)
|
||||||
} else {
|
} else {
|
||||||
repository.calendars()
|
repository.calendars()
|
||||||
.catch { emit(emptyList()) }
|
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||||
.map { calendars -> calendars.backupApplies() }
|
.catch { emit(null) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +145,18 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun setHasPermission(granted: Boolean) {
|
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.
|
* daily worker.
|
||||||
*/
|
*/
|
||||||
fun onPermissionGranted() {
|
fun onPermissionGranted() {
|
||||||
viewModelScope.launch { prefs.armOnboardingWizard() }
|
|
||||||
scanner.scanInBackground()
|
scanner.scanInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,12 +250,32 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Acknowledge the calendar-visibility notice (#75). */
|
/** Acknowledge the calendar-visibility notice (#75). */
|
||||||
fun finishVisibility() {
|
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. */
|
/** Close the wizard from its last screen. */
|
||||||
fun finishOnboarding() {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -337,6 +337,32 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
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
|
@Test
|
||||||
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
|||||||
@@ -103,6 +103,25 @@ class OnboardingPlanTest {
|
|||||||
assertThat(revoked.showsProgress).isFalse()
|
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
|
@Test
|
||||||
fun `answering every step ends the flow`() {
|
fun `answering every step ends the flow`() {
|
||||||
val done = plan(
|
val done = plan(
|
||||||
|
|||||||
Reference in New Issue
Block a user