diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index a111c89..71eaad3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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 = 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 = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt new file mode 100644 index 0000000..d364d0d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt @@ -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, + 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 }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt index dc2e5a5..dd3c68f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt index f0bb69a..0b37b7b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 9699eb7..e6cbf3e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -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?> = hasPermission + .flatMapLatest { granted -> + if (granted != true) { + flowOf(null) + } else { + val list: Flow?> = 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 = 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 = 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 = 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 = _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 = 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 = 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> = calendars + .map { it.orEmpty().orderedForManager() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptyList(), + ) + private val flags: Flow = 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 = 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.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. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94d1118..73cc474 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,6 +312,11 @@ Room for more Settings → Calendars makes as many as you need, and renames or recolours the ones you have. Continue + Your calendars are switched off + 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. + Switch them all on + Not now + Couldn\'t switch that on. Settings → Calendars has the same switches. Couldn\'t create the calendar. Your phone\'s calendar storage refused it. Choose folder and back up Not now diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt index 862ee79..0dfae8e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt @@ -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) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 780f683..ed3afde 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,6 +113,19 @@ notice (#75) is a step in front of it rather than a dialog over the app — it is the only optional step an *already onboarded* install can be given, which is why it alone is not gated on the wizard being armed. +"Every calendar is switched off" is a *state*, not that announcement, so it +is a step of its own at the front of the flow (#287) rather than another face +of the notice: the notice is settled once and for good, while the state is +derived from the live calendar list on every launch. It lists the calendars +with their own switches — switching one on un-hides it in every other calendar +app too, which is not a choice to make on the app's word — and it sits *in +front of* the calendar step, so declining it falls through to the offer to +make a calendar that a device with none at all gets. That is also why +`calendarsApplies` resolves on "something is visible" rather than "the list is +non-empty". Being derived, it is gated on the wizard being armed: an install +that switched its own calendars off from Settings meant it, and has the in-app +empty state (#239) instead. + ## Recurring writes The provider's invariants drive the design (learned the hard way, verified