Offer a calendar during setup when the device has none (#287)

This commit is contained in:
2026-09-23 17:58:21 +02:00
parent 2e4a428a9c
commit 76528f75dc
7 changed files with 302 additions and 1 deletions
@@ -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). */
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_BACKUP_KEY] ?: false
@@ -977,6 +986,8 @@ class SettingsPrefs @Inject constructor(
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
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_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
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. */
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. */
Reminders,
@@ -67,6 +73,12 @@ data class OnboardingPlan(
* otherwise, so the flow can only ever get shorter — never sprout a step the
* 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:
* 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
@@ -85,6 +97,8 @@ fun onboardingPlan(
viewDone: Boolean,
monthStyleDone: Boolean,
backupApplies: Boolean?,
calendarsDone: Boolean = false,
calendarsApplies: Boolean? = null,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
@@ -92,6 +106,7 @@ fun onboardingPlan(
val fresh = wizardArmed || (!hasPermission && !remindersDone)
val steps = buildList {
if (!hasPermission || fresh) add(OnboardingStep.Permission)
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
if (fresh) {
@@ -104,6 +119,7 @@ fun onboardingPlan(
val current = steps.firstOrNull { step ->
when (step) {
OnboardingStep.Permission -> !hasPermission
OnboardingStep.Calendars -> !calendarsDone
OnboardingStep.Reminders -> !remindersDone
OnboardingStep.Backup -> !backupDone
OnboardingStep.View -> !viewDone
@@ -67,6 +67,13 @@ fun OnboardingSteps(
modifier = modifier,
progress = progress,
)
OnboardingStep.Calendars -> CalendarsStep(
onCreateLocal = viewModel::createLocalCalendar,
onSkip = viewModel::skipCalendars,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
OnboardingStep.Reminders -> ReminderStep(
onFinished = viewModel::finishReminders,
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.prefs.CalendarPrefs
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.reminders.ReminderScanner
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(
prefs.reminderOnboardingDone,
prefs.onboardingWizardArmed,
@@ -110,7 +135,13 @@ class OnboardingViewModel @Inject constructor(
)
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 {
onboardingPlan(
hasPermission = it,
@@ -120,6 +151,8 @@ class OnboardingViewModel @Inject constructor(
viewDone = stored.viewDone,
monthStyleDone = stored.monthStyleDone,
backupApplies = backup,
calendarsDone = calendars.done,
calendarsApplies = calendars.applies,
visibilityArmed = closing.visibilityArmed,
visibilityDone = closing.visibilityDone,
doneShown = closing.doneShown,
@@ -186,6 +219,7 @@ class OnboardingViewModel @Inject constructor(
fun goBack() {
viewModelScope.launch {
when (plan.value?.previous) {
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
@@ -202,6 +236,33 @@ class OnboardingViewModel @Inject constructor(
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
* (taking a durable write grant so background runs can keep writing), and
@@ -295,6 +356,9 @@ private data class OnboardingFlags(
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. */
private data class ClosingFlags(
val visibilityArmed: Boolean,
+11
View File
@@ -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_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_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_skip_button">Not now</string>
<string name="onboarding_view_title">What should open first?</string>
@@ -18,6 +18,8 @@ class OnboardingPlanTest {
viewDone: Boolean = false,
monthStyleDone: Boolean = false,
backupApplies: Boolean? = null,
calendarsDone: Boolean = false,
calendarsApplies: Boolean? = false,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
@@ -29,6 +31,8 @@ class OnboardingPlanTest {
viewDone = viewDone,
monthStyleDone = monthStyleDone,
backupApplies = backupApplies,
calendarsDone = calendarsDone,
calendarsApplies = calendarsApplies,
visibilityArmed = visibilityArmed,
visibilityDone = visibilityDone,
doneShown = doneShown,
@@ -243,4 +247,61 @@ class OnboardingPlanTest {
)
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)
}
}