diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 9d8d7fa..a111c89 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -586,6 +586,15 @@ class SettingsPrefs @Inject constructor( } } + /** Whether the wizard's calendar step has been answered (or skipped). */ + val onboardingCalendarsDone: Flow = 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 = 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 = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt new file mode 100644 index 0000000..06c61eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt @@ -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 + * `` 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 } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt index ae820fe..dc2e5a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt index 2fecbb9..fb814e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 06e6472..04bd974 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -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 = 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 = combine( + calendarsApplies, + prefs.onboardingCalendarsDone, + ::CalendarsFlags, + ) + private val flags: Flow = combine( prefs.reminderOnboardingDone, prefs.onboardingWizardArmed, @@ -110,7 +135,13 @@ class OnboardingViewModel @Inject constructor( ) val plan: StateFlow = - 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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e18d0c..f02cb44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -298,6 +298,17 @@ Backups are plain .ics files — put them somewhere that syncs, or on an SD card. Once a day, by itself Calendula exports your local calendars in the background. Change how often in Settings. + Somewhere to put your events + This device has no calendar yet. Calendula writes to the calendars already on your phone — it needs at least one. + My calendar + Create a calendar on this device + Add an account + Using Nextcloud or another CalDAV server? + Not now + Stays on this phone + A device calendar syncs nowhere, so back it up if the events matter. + Or sync from an account + Calendula shows what your accounts already sync. CalDAV servers need DAVx\u2075 to do the syncing. Choose folder and back up Not now What should open first? diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt index fe0a521..862ee79 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt @@ -18,6 +18,8 @@ class OnboardingPlanTest { viewDone: Boolean = false, monthStyleDone: Boolean = false, backupApplies: Boolean? = null, + 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) + } }