Show the switched-off calendars during setup (#327)
This commit is contained in:
@@ -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,
|
||||
|
||||
+145
-26
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user