Offer a calendar during setup when the device has none (#287)
This commit is contained in:
@@ -586,6 +586,15 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||||
|
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
@@ -977,6 +986,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||||
|
internal val ONBOARDING_CALENDARS_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_calendars_done")
|
||||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.CloudSync
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.settings.openUrl
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/** Where the sync branch sends someone who needs a CalDAV client (#287). */
|
||||||
|
private const val DAVX5_URL = "https://www.davx5.com/"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step shown only when the device has no calendar at all (#287): the app
|
||||||
|
* would otherwise finish onboarding onto an empty grid with nothing saying why.
|
||||||
|
*
|
||||||
|
* Three ways out, in the order most people want them. Calendula writes to the
|
||||||
|
* system calendar provider and syncs nothing itself, so two of the three are
|
||||||
|
* handing off to something that does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CalendarsStep(
|
||||||
|
onCreateLocal: (String) -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val localName = stringResource(R.string.onboarding_calendars_local_name)
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.CalendarMonth) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = { onCreateLocal(localName) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_local_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { context.startCalendarAccountSetup() },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_calendars_account_button))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { openUrl(context, DAVX5_URL) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_calendars_caldav_button))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_calendars_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.PhoneAndroid,
|
||||||
|
title = stringResource(R.string.onboarding_calendars_benefit_local_title),
|
||||||
|
body = stringResource(R.string.onboarding_calendars_benefit_local_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.CloudSync,
|
||||||
|
title = stringResource(R.string.onboarding_calendars_benefit_sync_title),
|
||||||
|
body = stringResource(R.string.onboarding_calendars_benefit_sync_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system screen for putting an account's calendars on the device: the
|
||||||
|
* account chooser filtered to calendar providers, falling back to sync settings
|
||||||
|
* where no chooser resolves. Both are system components, so neither needs a
|
||||||
|
* `<queries>` entry. Mirrors the same hand-off in `CalendarFailure`.
|
||||||
|
*/
|
||||||
|
private fun Context.startCalendarAccountSetup() {
|
||||||
|
val intents = listOf(
|
||||||
|
Intent(Settings.ACTION_ADD_ACCOUNT)
|
||||||
|
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
||||||
|
Intent(Settings.ACTION_SYNC_SETTINGS),
|
||||||
|
)
|
||||||
|
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ enum class OnboardingStep {
|
|||||||
/** Required: nothing works without the calendar grant. */
|
/** Required: nothing works without the calendar grant. */
|
||||||
Permission,
|
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).
|
||||||
|
*/
|
||||||
|
Calendars,
|
||||||
|
|
||||||
/** Whether Calendula delivers reminder notifications itself. */
|
/** Whether Calendula delivers reminder notifications itself. */
|
||||||
Reminders,
|
Reminders,
|
||||||
|
|
||||||
@@ -67,6 +73,12 @@ data class OnboardingPlan(
|
|||||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||||
* counter had not accounted for.
|
* counter had not accounted for.
|
||||||
*
|
*
|
||||||
|
* [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.
|
||||||
|
*
|
||||||
* The month-style step is *not* conditional on Month being the chosen view:
|
* 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
|
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||||
* and went as the view is picked would move the counter under the user on the
|
* and went as the view is picked would move the counter under the user on the
|
||||||
@@ -85,6 +97,8 @@ fun onboardingPlan(
|
|||||||
viewDone: Boolean,
|
viewDone: Boolean,
|
||||||
monthStyleDone: Boolean,
|
monthStyleDone: Boolean,
|
||||||
backupApplies: Boolean?,
|
backupApplies: Boolean?,
|
||||||
|
calendarsDone: Boolean = false,
|
||||||
|
calendarsApplies: Boolean? = null,
|
||||||
visibilityArmed: Boolean = false,
|
visibilityArmed: Boolean = false,
|
||||||
visibilityDone: Boolean = false,
|
visibilityDone: Boolean = false,
|
||||||
doneShown: Boolean = false,
|
doneShown: Boolean = false,
|
||||||
@@ -92,6 +106,7 @@ fun onboardingPlan(
|
|||||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||||
val steps = buildList {
|
val steps = buildList {
|
||||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||||
|
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
|
||||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
@@ -104,6 +119,7 @@ fun onboardingPlan(
|
|||||||
val current = steps.firstOrNull { step ->
|
val current = steps.firstOrNull { step ->
|
||||||
when (step) {
|
when (step) {
|
||||||
OnboardingStep.Permission -> !hasPermission
|
OnboardingStep.Permission -> !hasPermission
|
||||||
|
OnboardingStep.Calendars -> !calendarsDone
|
||||||
OnboardingStep.Reminders -> !remindersDone
|
OnboardingStep.Reminders -> !remindersDone
|
||||||
OnboardingStep.Backup -> !backupDone
|
OnboardingStep.Backup -> !backupDone
|
||||||
OnboardingStep.View -> !viewDone
|
OnboardingStep.View -> !viewDone
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ fun OnboardingSteps(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
)
|
)
|
||||||
|
OnboardingStep.Calendars -> CalendarsStep(
|
||||||
|
onCreateLocal = viewModel::createLocalCalendar,
|
||||||
|
onSkip = viewModel::skipCalendars,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
OnboardingStep.Reminders -> ReminderStep(
|
OnboardingStep.Reminders -> ReminderStep(
|
||||||
onFinished = viewModel::finishReminders,
|
onFinished = viewModel::finishReminders,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -92,6 +93,30 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private val calendarsApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||||
|
if (granted != true) {
|
||||||
|
flowOf(null)
|
||||||
|
} else {
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> if (calendars.isEmpty()) null else false }
|
||||||
|
.catch { emit(null) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The calendar step's own pair: whether it applies, and whether it is answered. */
|
||||||
|
private val calendarsFlags: Flow<CalendarsFlags> = combine(
|
||||||
|
calendarsApplies,
|
||||||
|
prefs.onboardingCalendarsDone,
|
||||||
|
::CalendarsFlags,
|
||||||
|
)
|
||||||
|
|
||||||
private val flags: Flow<OnboardingFlags> = combine(
|
private val flags: Flow<OnboardingFlags> = combine(
|
||||||
prefs.reminderOnboardingDone,
|
prefs.reminderOnboardingDone,
|
||||||
prefs.onboardingWizardArmed,
|
prefs.onboardingWizardArmed,
|
||||||
@@ -110,7 +135,13 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val plan: StateFlow<OnboardingPlan?> =
|
val plan: StateFlow<OnboardingPlan?> =
|
||||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
combine(
|
||||||
|
hasPermission,
|
||||||
|
flags,
|
||||||
|
closingFlags,
|
||||||
|
backupApplies,
|
||||||
|
calendarsFlags,
|
||||||
|
) { granted, stored, closing, backup, calendars ->
|
||||||
granted?.let {
|
granted?.let {
|
||||||
onboardingPlan(
|
onboardingPlan(
|
||||||
hasPermission = it,
|
hasPermission = it,
|
||||||
@@ -120,6 +151,8 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewDone = stored.viewDone,
|
viewDone = stored.viewDone,
|
||||||
monthStyleDone = stored.monthStyleDone,
|
monthStyleDone = stored.monthStyleDone,
|
||||||
backupApplies = backup,
|
backupApplies = backup,
|
||||||
|
calendarsDone = calendars.done,
|
||||||
|
calendarsApplies = calendars.applies,
|
||||||
visibilityArmed = closing.visibilityArmed,
|
visibilityArmed = closing.visibilityArmed,
|
||||||
visibilityDone = closing.visibilityDone,
|
visibilityDone = closing.visibilityDone,
|
||||||
doneShown = closing.doneShown,
|
doneShown = closing.doneShown,
|
||||||
@@ -186,6 +219,7 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
fun goBack() {
|
fun goBack() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (plan.value?.previous) {
|
when (plan.value?.previous) {
|
||||||
|
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
||||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||||
@@ -202,6 +236,33 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the device-only calendar the step offers and close it. Named from
|
||||||
|
* the caller so the string stays with the rest of the step's text.
|
||||||
|
*/
|
||||||
|
fun createLocalCalendar(displayName: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repository.createLocalCalendar(
|
||||||
|
displayName = displayName,
|
||||||
|
color = CalendarColorPalette.all.first(),
|
||||||
|
description = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Closed either way: a provider that refused the insert will not
|
||||||
|
// succeed on a second press, and the step is skippable regardless.
|
||||||
|
prefs.setOnboardingCalendarsDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the calendar step without making one. An empty app is a legitimate
|
||||||
|
* choice, and so is going off to set up an account and coming back.
|
||||||
|
*/
|
||||||
|
fun skipCalendars() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn automatic backup on, writing to the folder the user just picked
|
* Turn automatic backup on, writing to the folder the user just picked
|
||||||
* (taking a durable write grant so background runs can keep writing), and
|
* (taking a durable write grant so background runs can keep writing), and
|
||||||
@@ -295,6 +356,9 @@ private data class OnboardingFlags(
|
|||||||
val monthStyleDone: Boolean,
|
val monthStyleDone: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The calendar step's inputs (#287). */
|
||||||
|
private data class CalendarsFlags(val applies: Boolean?, val done: Boolean)
|
||||||
|
|
||||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||||
private data class ClosingFlags(
|
private data class ClosingFlags(
|
||||||
val visibilityArmed: Boolean,
|
val visibilityArmed: Boolean,
|
||||||
|
|||||||
@@ -298,6 +298,17 @@
|
|||||||
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||||
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||||
|
<string name="onboarding_calendars_title">Somewhere to put your events</string>
|
||||||
|
<string name="onboarding_calendars_body">This device has no calendar yet. Calendula writes to the calendars already on your phone — it needs at least one.</string>
|
||||||
|
<string name="onboarding_calendars_local_name">My calendar</string>
|
||||||
|
<string name="onboarding_calendars_local_button">Create a calendar on this device</string>
|
||||||
|
<string name="onboarding_calendars_account_button">Add an account</string>
|
||||||
|
<string name="onboarding_calendars_caldav_button">Using Nextcloud or another CalDAV server?</string>
|
||||||
|
<string name="onboarding_calendars_skip_button">Not now</string>
|
||||||
|
<string name="onboarding_calendars_benefit_local_title">Stays on this phone</string>
|
||||||
|
<string name="onboarding_calendars_benefit_local_body">A device calendar syncs nowhere, so back it up if the events matter.</string>
|
||||||
|
<string name="onboarding_calendars_benefit_sync_title">Or sync from an account</string>
|
||||||
|
<string name="onboarding_calendars_benefit_sync_body">Calendula shows what your accounts already sync. CalDAV servers need DAVx\u2075 to do the syncing.</string>
|
||||||
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||||
<string name="onboarding_backup_skip_button">Not now</string>
|
<string name="onboarding_backup_skip_button">Not now</string>
|
||||||
<string name="onboarding_view_title">What should open first?</string>
|
<string name="onboarding_view_title">What should open first?</string>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class OnboardingPlanTest {
|
|||||||
viewDone: Boolean = false,
|
viewDone: Boolean = false,
|
||||||
monthStyleDone: Boolean = false,
|
monthStyleDone: Boolean = false,
|
||||||
backupApplies: Boolean? = null,
|
backupApplies: Boolean? = null,
|
||||||
|
calendarsDone: Boolean = false,
|
||||||
|
calendarsApplies: Boolean? = false,
|
||||||
visibilityArmed: Boolean = false,
|
visibilityArmed: Boolean = false,
|
||||||
visibilityDone: Boolean = false,
|
visibilityDone: Boolean = false,
|
||||||
doneShown: Boolean = false,
|
doneShown: Boolean = false,
|
||||||
@@ -29,6 +31,8 @@ class OnboardingPlanTest {
|
|||||||
viewDone = viewDone,
|
viewDone = viewDone,
|
||||||
monthStyleDone = monthStyleDone,
|
monthStyleDone = monthStyleDone,
|
||||||
backupApplies = backupApplies,
|
backupApplies = backupApplies,
|
||||||
|
calendarsDone = calendarsDone,
|
||||||
|
calendarsApplies = calendarsApplies,
|
||||||
visibilityArmed = visibilityArmed,
|
visibilityArmed = visibilityArmed,
|
||||||
visibilityDone = visibilityDone,
|
visibilityDone = visibilityDone,
|
||||||
doneShown = doneShown,
|
doneShown = doneShown,
|
||||||
@@ -243,4 +247,61 @@ class OnboardingPlanTest {
|
|||||||
)
|
)
|
||||||
assertThat(read.current).isNull()
|
assertThat(read.current).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the calendar step (#287) -----------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a device with no calendar is offered one, right after the grant`() {
|
||||||
|
val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||||
|
assertThat(fresh.steps).contains(OnboardingStep.Calendars)
|
||||||
|
assertThat(fresh.current).isEqualTo(OnboardingStep.Calendars)
|
||||||
|
// Before reminders: there is no point asking about notifications for
|
||||||
|
// events that have nowhere to live.
|
||||||
|
assertThat(fresh.steps.indexOf(OnboardingStep.Calendars))
|
||||||
|
.isLessThan(fresh.steps.indexOf(OnboardingStep.Reminders))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a device that already has a calendar never sees the step`() {
|
||||||
|
val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false)
|
||||||
|
assertThat(fresh.steps).doesNotContain(OnboardingStep.Calendars)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the step is assumed to apply until a calendar list proves otherwise`() {
|
||||||
|
// Same contract the backup step keeps: an unknown answer keeps the step,
|
||||||
|
// so the plan can only ever get shorter and the counter never grows.
|
||||||
|
val unknown = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||||
|
val known = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false)
|
||||||
|
assertThat(unknown.steps).contains(OnboardingStep.Calendars)
|
||||||
|
assertThat(known.total).isLessThan(unknown.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `answering the step moves on without renumbering the flow`() {
|
||||||
|
val before = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||||
|
val after = before.let {
|
||||||
|
plan(
|
||||||
|
hasPermission = true,
|
||||||
|
wizardArmed = true,
|
||||||
|
calendarsApplies = null,
|
||||||
|
calendarsDone = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertThat(after.current).isEqualTo(OnboardingStep.Reminders)
|
||||||
|
assertThat(after.steps).isEqualTo(before.steps)
|
||||||
|
assertThat(after.total).isEqualTo(before.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an existing install is never given the calendar step`() {
|
||||||
|
// Not gated on `fresh` would re-onboard everyone whose read momentarily
|
||||||
|
// came back empty.
|
||||||
|
val existing = plan(
|
||||||
|
hasPermission = true,
|
||||||
|
remindersDone = true,
|
||||||
|
calendarsApplies = null,
|
||||||
|
)
|
||||||
|
assertThat(existing.steps).doesNotContain(OnboardingStep.Calendars)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user