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). */
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user