Show the switched-off calendars during setup (#327)

This commit is contained in:
Jean-Luc Makiola
2026-09-23 21:18:53 +02:00
parent c3d6847430
commit 66f6f10b27
8 changed files with 470 additions and 31 deletions
@@ -595,6 +595,15 @@ class SettingsPrefs @Inject constructor(
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
}
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_HIDDEN_KEY] ?: false
}
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
}
/** Whether the wizard's backup step has been answered (or skipped). */
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_BACKUP_KEY] ?: false
@@ -988,6 +997,8 @@ class SettingsPrefs @Inject constructor(
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
internal val ONBOARDING_CALENDARS_KEY =
booleanPreferencesKey("onboarding_calendars_done")
internal val ONBOARDING_HIDDEN_KEY =
booleanPreferencesKey("onboarding_hidden_calendars_done")
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
internal val ONBOARDING_MONTH_STYLE_KEY =
@@ -0,0 +1,173 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.Position
/**
* Wizard step for a device whose every calendar is switched off (#287), which
* would otherwise open the app on a blank grid with nothing saying why.
*
* The calendars are listed rather than summarised: which ones exist and whose
* account they came from is the whole of what there is to decide, and switching
* them on shows them in every other calendar app too — not a choice to make on
* the app's word. Each row carries the same switch Settings does; the button
* above them does all of it at once.
*
* Declining leads into the calendar step behind this one, which offers to make a
* calendar instead — the same offer a device with none at all gets.
*/
@Composable
internal fun HiddenCalendarsStep(
calendars: List<CalendarSource>,
onSetVisible: (id: Long, visible: Boolean) -> Unit,
onSwitchAllOn: () -> Unit,
onFinished: () -> Unit,
writeFailed: Boolean,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
val anyVisible = calendars.any { it.isVisibleInSystem }
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.sm,
contentPadding = 0.dp,
// The list is as long as the device's calendars; pinned actions would
// eat the room it needs and hide that there is more below.
scrollingActions = true,
hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = {
Button(
onClick = if (anyVisible) onFinished else onSwitchAllOn,
modifier = Modifier
.padding(horizontal = GroupedListInset)
.fillMaxWidth()
.height(56.dp),
) {
Text(
text = stringResource(
if (anyVisible) {
R.string.onboarding_view_continue_button
} else {
R.string.onboarding_hidden_switch_all_button
},
),
style = MaterialTheme.typography.titleMedium,
)
}
// Only while nothing is on: once something is, the step has been
// answered and the one button left to press says so.
if (!anyVisible) {
TextButton(
onClick = onFinished,
modifier = Modifier
.padding(horizontal = GroupedListInset)
.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_hidden_skip_button))
}
}
},
) {
Column(Modifier.padding(horizontal = GroupedListInset)) {
Text(
text = stringResource(R.string.onboarding_hidden_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_hidden_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
if (writeFailed) {
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_hidden_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(OnboardingSpace.md))
calendars.forEachIndexed { index, calendar ->
val off = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName,
summary = calendar.accountName.takeIf { it.isNotBlank() },
position = when {
calendars.size == 1 -> Position.Alone
index == 0 -> Position.Top
index == calendars.lastIndex -> Position.Bottom
else -> Position.Middle
},
dimmed = off,
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarSwitch(
calendarName = calendar.displayName,
enabled = !off,
onToggle = { on -> onSetVisible(calendar.id, on) },
)
}
} else {
null
},
leading = { CalendarColorChip(calendar.color) },
)
}
Spacer(Modifier.height(OnboardingSpace.md))
}
}
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
@Composable
private fun CalendarSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -6,8 +6,15 @@ enum class OnboardingStep {
Permission,
/**
* Only when the device has no calendar at all — the app can make a local
* one, or send you to whatever would do the syncing (#287).
* Only when every calendar on the device is switched off, so the app would
* open on a blank grid: lists them and offers to switch them back on (#287).
*/
HiddenCalendars,
/**
* Only when the device has nothing to put an event in — no calendar at all,
* or none left showing after the step above. The app can make a local one,
* or send you to whatever would do the syncing (#287).
*/
Calendars,
@@ -76,8 +83,14 @@ data class OnboardingPlan(
* [calendarsApplies] carries the same contract for the calendar step, with one
* wrinkle: for the backup step an empty list means "could not read", but here
* an empty list *is* the condition. The two are indistinguishable at this
* layer and both want the step, so it turns false only once a non-empty list
* has actually arrived.
* layer and both want the step, so it turns false only once a calendar the app
* could actually show has arrived.
*
* [hiddenApplies] is the step in front of it (#287): the device has calendars
* but every one of them is switched off. It comes first because switching one
* back on is the lighter remedy — and where it is declined, the calendar step
* behind it still offers to make one, the same offer a device with no calendars
* at all gets.
*
* The month-style step is *not* conditional on Month being the chosen view:
* Month is reachable from the drawer whatever opens first, and a step that came
@@ -87,7 +100,9 @@ data class OnboardingPlan(
* [visibilityArmed] carries the one-time notice that the device is hiding some
* calendars (#75) — the only optional step an *existing* install can be given,
* which is why it is not gated on `fresh`, and on its own it earns no closing
* screen.
* screen. It stays a pure announcement: the all-hidden *state* is
* [hiddenApplies]'s business, up at the front of the flow where something can
* still be done about it.
*/
fun onboardingPlan(
hasPermission: Boolean,
@@ -97,6 +112,8 @@ fun onboardingPlan(
viewDone: Boolean,
monthStyleDone: Boolean,
backupApplies: Boolean?,
hiddenDone: Boolean = false,
hiddenApplies: Boolean? = false,
calendarsDone: Boolean = false,
calendarsApplies: Boolean? = null,
visibilityArmed: Boolean = false,
@@ -106,6 +123,7 @@ fun onboardingPlan(
val fresh = wizardArmed || (!hasPermission && !remindersDone)
val steps = buildList {
if (!hasPermission || fresh) add(OnboardingStep.Permission)
if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars)
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
@@ -119,6 +137,7 @@ fun onboardingPlan(
val current = steps.firstOrNull { step ->
when (step) {
OnboardingStep.Permission -> !hasPermission
OnboardingStep.HiddenCalendars -> !hiddenDone
OnboardingStep.Calendars -> !calendarsDone
OnboardingStep.Reminders -> !remindersDone
OnboardingStep.Backup -> !backupDone
@@ -29,6 +29,8 @@ fun OnboardingSteps(
// it when the picker composes would leave that step blank as it slides in.
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
val creation by viewModel.creation.collectAsStateWithLifecycle()
val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle()
val hiddenWriteFailed by viewModel.hiddenWriteFailed.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
@@ -68,6 +70,16 @@ fun OnboardingSteps(
modifier = modifier,
progress = progress,
)
OnboardingStep.HiddenCalendars -> HiddenCalendarsStep(
calendars = hiddenCalendars,
onSetVisible = viewModel::setHiddenCalendarVisible,
onSwitchAllOn = viewModel::switchAllCalendarsOn,
onFinished = viewModel::finishHiddenCalendars,
writeFailed = hiddenWriteFailed,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
OnboardingStep.Calendars -> CalendarsStep(
onCreateLocal = viewModel::createLocalCalendar,
onSkip = viewModel::skipCalendars,
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import kotlinx.coroutines.CoroutineDispatcher
@@ -75,6 +77,27 @@ class OnboardingViewModel @Inject constructor(
initialValue = null,
)
/**
* The calendar list every derived step reads, collected once: one per step
* would put a provider query each on every visibility tick, and let the
* steps judge different snapshots of the same moment. Null is "no list to
* judge" — before the grant, and on a read that failed.
*/
private val calendars: StateFlow<List<CalendarSource>?> = hasPermission
.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
val list: Flow<List<CalendarSource>?> = repository.calendars()
list.catch { emit(null) }.flowOn(io)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
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
@@ -82,15 +105,8 @@ class OnboardingViewModel @Inject constructor(
* an empty list is also how a null cursor arrives, and 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()
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
.catch { emit(null) }
.flowOn(io)
}
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
if (list.isNullOrEmpty()) null else list.backupApplies()
}
/** How the step's own create attempt ended, while it is still saying so (#287). */
@@ -107,32 +123,72 @@ class OnboardingViewModel @Inject constructor(
private val calendarCreatedHere = MutableStateFlow(false)
/**
* Whether the calendar step applies: the device has nothing to put events
* in. Unlike [backupApplies] an empty list is the condition itself, not a
* failed read — the two look identical here and both want the step, so this
* only resolves once a non-empty list proves there is a calendar already.
* Whether the calendar step applies: the device has nothing to show and so
* nothing to put events in. Unlike [backupApplies] an empty list is the
* condition itself, not a failed read — the two look identical here and both
* want the step, so this only resolves once a calendar the app can actually
* show has arrived. Calendars that exist but are all switched off leave it
* unresolved too: the step in front (#287) offers to switch them on, and
* declining that lands here, on the same offer a device with none gets.
*/
private val calendarsApplies: Flow<Boolean?> = combine(
hasPermission.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
repository.calendars()
.map { calendars -> if (calendars.isEmpty()) null else false }
.catch { emit(null) }
.flowOn(io)
}
},
calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null },
calendarCreatedHere,
) { applies, latched -> if (latched) null else applies }
/** The calendar step's own pair: whether it applies, and whether it is answered. */
/**
* Set when the step starts a write of its own, and never cleared: from that
* moment the step holds its place in the plan until it is answered. Set
* *before* the write rather than after it, because a successful one answers
* [hiddenApplies] with false — the step would drop out between the provider
* taking the change and the latch seeing it.
*/
private val hiddenTouched = MutableStateFlow(false)
/** A visibility write the provider did not take, while the step says so (#287). */
private val _hiddenWriteFailed = MutableStateFlow(false)
val hiddenWriteFailed: StateFlow<Boolean> = _hiddenWriteFailed.asStateFlow()
/**
* Whether the device is showing nothing at all right now: calendars exist,
* none is visible, and at least one of them has a switch the step can flip
* (#287). Null only while there is no list to judge — before the grant, and
* on a read that failed — which keeps the step in the plan until the answer
* arrives, the same shrink-only contract [backupApplies] keeps.
*
* An empty list answers false rather than null, unlike [calendarsApplies]:
* a phone with no calendars at all is the next step's business, and this one
* would be listing calendars it cannot see. [hiddenTouched] holds it at null
* from the step's first write onwards.
*/
private val hiddenApplies: Flow<Boolean?> = combine(
calendars.map { list -> list?.allHidden() },
hiddenTouched,
) { hidden, touched -> if (touched) null else hidden }
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
private val calendarsFlags: Flow<CalendarsFlags> = combine(
calendarsApplies,
prefs.onboardingCalendarsDone,
hiddenApplies,
prefs.onboardingHiddenCalendarsDone,
::CalendarsFlags,
)
/**
* The calendars the hidden-calendars step lists, so the user can judge which
* to switch on rather than take the app's word that there are some. Live: a
* row follows its own switch, and the ones without one stay listed anyway —
* being told a calendar exists is half the point.
*/
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
.map { it.orEmpty().orderedForManager() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
private val flags: Flow<OnboardingFlags> = combine(
prefs.reminderOnboardingDone,
prefs.onboardingWizardArmed,
@@ -167,6 +223,8 @@ class OnboardingViewModel @Inject constructor(
viewDone = stored.viewDone,
monthStyleDone = stored.monthStyleDone,
backupApplies = backup,
hiddenDone = calendars.hiddenDone,
hiddenApplies = calendars.hiddenApplies,
calendarsDone = calendars.done,
calendarsApplies = calendars.applies,
visibilityArmed = closing.visibilityArmed,
@@ -235,6 +293,7 @@ class OnboardingViewModel @Inject constructor(
fun goBack() {
viewModelScope.launch {
when (plan.value?.previous) {
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
@@ -343,6 +402,52 @@ class OnboardingViewModel @Inject constructor(
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
}
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite {
repository.setCalendarsVisible(listOf(id), visible)
calendarList().any { it.id == id && it.isVisibleInSystem == visible }
}
/** Switch every hidden calendar back on — the step's one-tap way out (#287). */
fun switchAllCalendarsOn() = visibilityWrite {
val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem }
// Nothing to write is not a write that landed: a list that came back
// empty (a failed read) would otherwise report success and leave the
// step sitting there with no rows and no explanation.
if (hidden.isEmpty()) return@visibilityWrite false
repository.setCalendarsVisible(hidden.map { it.id }, true)
calendarList().any { it.isVisibleInSystem }
}
/**
* Run one of the step's visibility writes, whose lambda answers whether it
* took. Checked rather than assumed: with read-only calendar access the
* choice is kept app-side (#75) and nothing the grid shows would change.
*/
private fun visibilityWrite(landed: suspend () -> Boolean) {
viewModelScope.launch {
hiddenTouched.value = true
_hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false)
}
}
private suspend fun calendarList(): List<CalendarSource> = repository.calendars().first()
/** Close the hidden-calendars step, switched on or left as they were (#287). */
fun finishHiddenCalendars() {
viewModelScope.launch {
prefs.setOnboardingHiddenCalendarsDone()
// The error belongs to the attempt, not to the step: stepping back
// into it later must not find a refusal nothing has asked for yet.
_hiddenWriteFailed.value = false
val list = calendarList()
// Switching them all back on answers the one-time notice too (#75) —
// it would otherwise announce, at the end of this same flow, that the
// device is hiding calendars it no longer hides.
if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice()
}
}
/** Acknowledge the calendar-visibility notice (#75). */
fun finishVisibility() {
viewModelScope.launch {
@@ -398,8 +503,13 @@ sealed interface CalendarCreation {
data object Failed : CalendarCreation
}
/** The calendar step's inputs (#287). */
private data class CalendarsFlags(val applies: Boolean?, val done: Boolean)
/** Both calendar steps' inputs (#287). */
private data class CalendarsFlags(
val applies: Boolean?,
val done: Boolean,
val hiddenApplies: Boolean?,
val hiddenDone: Boolean,
)
/** The tail of the flow: the visibility notice and the closing screen. */
private data class ClosingFlags(
@@ -415,6 +525,15 @@ data class ViewChoice(
val weekStart: WeekStartPref,
)
/**
* True when this device would show nothing and the step could change that: no
* calendar is visible, and at least one of them has a switch worth flipping —
* a non-syncing calendar holds no events on the device, so switching it on
* leaves the grid exactly as empty (#287).
*/
private fun List<CalendarSource>.allHidden(): Boolean =
any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem }
/**
* True when this device holds events that live nowhere else: at least one local
* calendar worth exporting, and no writable synced calendar to carry them.
+5
View File
@@ -312,6 +312,11 @@
<string name="onboarding_calendars_created_more_title">Room for more</string>
<string name="onboarding_calendars_created_more_body">Settings → Calendars makes as many as you need, and renames or recolours the ones you have.</string>
<string name="onboarding_calendars_created_continue">Continue</string>
<string name="onboarding_hidden_title">Your calendars are switched off</string>
<string name="onboarding_hidden_body">Calendula shows the calendars this phone has switched on, and right now that is none of them. Switch on the ones you want to see — your other calendar apps show them again too.</string>
<string name="onboarding_hidden_switch_all_button">Switch them all on</string>
<string name="onboarding_hidden_skip_button">Not now</string>
<string name="onboarding_hidden_failed">Couldn\'t switch that on. Settings → Calendars has the same switches.</string>
<string name="onboarding_calendars_create_failed">Couldn\'t create the calendar. Your phone\'s calendar storage refused it.</string>
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
<string name="onboarding_backup_skip_button">Not now</string>
@@ -18,6 +18,8 @@ class OnboardingPlanTest {
viewDone: Boolean = false,
monthStyleDone: Boolean = false,
backupApplies: Boolean? = null,
hiddenDone: Boolean = false,
hiddenApplies: Boolean? = false,
calendarsDone: Boolean = false,
calendarsApplies: Boolean? = false,
visibilityArmed: Boolean = false,
@@ -31,6 +33,8 @@ class OnboardingPlanTest {
viewDone = viewDone,
monthStyleDone = monthStyleDone,
backupApplies = backupApplies,
hiddenDone = hiddenDone,
hiddenApplies = hiddenApplies,
calendarsDone = calendarsDone,
calendarsApplies = calendarsApplies,
visibilityArmed = visibilityArmed,
@@ -304,4 +308,87 @@ class OnboardingPlanTest {
)
assertThat(existing.steps).doesNotContain(OnboardingStep.Calendars)
}
// --- every calendar switched off (#287) -------------------------------
@Test
fun `a fresh install whose calendars are all off is shown them first`() {
// Nothing visible leaves the calendar step unresolved too, which is what
// puts the two in the same flow.
val hidden = plan(
hasPermission = true,
wizardArmed = true,
hiddenApplies = true,
calendarsApplies = null,
)
assertThat(hidden.current).isEqualTo(OnboardingStep.HiddenCalendars)
// Switching one back on is the lighter remedy, so it comes before the
// offer to make a calendar.
assertThat(hidden.steps.indexOf(OnboardingStep.HiddenCalendars))
.isLessThan(hidden.steps.indexOf(OnboardingStep.Calendars))
}
@Test
fun `declining them leaves the offer to make a calendar standing`() {
// The whole point of the order: someone who will not switch their
// calendars on still gets the offer a device with none at all gets.
val declined = plan(
hasPermission = true,
wizardArmed = true,
hiddenApplies = true,
hiddenDone = true,
calendarsApplies = null,
)
assertThat(declined.current).isEqualTo(OnboardingStep.Calendars)
}
@Test
fun `switching one on drops the offer to make a calendar`() {
// Something is showing now, so there is somewhere to put an event; the
// step the user is standing on is held in place by the view model.
val fixed = plan(
hasPermission = true,
wizardArmed = true,
hiddenApplies = null,
calendarsApplies = false,
)
assertThat(fixed.steps).contains(OnboardingStep.HiddenCalendars)
assertThat(fixed.steps).doesNotContain(OnboardingStep.Calendars)
}
@Test
fun `a fresh install with something visible never sees the step`() {
val visible = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false)
assertThat(visible.steps).doesNotContain(OnboardingStep.HiddenCalendars)
}
@Test
fun `the step is assumed until the calendar list proves something is visible`() {
// The unreadable answer keeps the step, like the ones around it: the flow
// shrinks when the list arrives, and the counter never grows.
val unknown = plan(hasPermission = true, wizardArmed = true, hiddenApplies = null)
val known = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false)
assertThat(unknown.steps).contains(OnboardingStep.HiddenCalendars)
assertThat(known.total).isLessThan(unknown.total)
}
@Test
fun `an existing install that switched its own calendars off is left alone`() {
// Hiding every calendar from Settings is a deliberate act with an empty
// state of its own (#239); it must not reopen the wizard.
val existing = plan(hasPermission = true, remindersDone = true, hiddenApplies = true)
assertThat(existing.steps).isEmpty()
assertThat(existing.current).isNull()
}
@Test
fun `the upgrade notice stays a notice at the end of the flow`() {
val armed = plan(
hasPermission = true,
remindersDone = true,
visibilityArmed = true,
hiddenApplies = true,
)
assertThat(armed.steps).containsExactly(OnboardingStep.Visibility)
}
}