Confirm the calendar the setup step made (#287)

This commit is contained in:
2026-09-23 19:13:45 +02:00
parent 10268492c6
commit 4879f939c7
4 changed files with 116 additions and 17 deletions
@@ -5,8 +5,10 @@ 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.CheckCircle
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -40,11 +42,16 @@ private const val DAVX5_URL = "https://www.davx5.com/"
* Two ways out. Calendula writes to the system calendar provider and syncs
* nothing itself, so anything beyond a device-only calendar means pointing at
* whatever does the syncing.
*
* [createdName] switches the step to confirming the calendar it just made,
* which is also the one place to say that more of them live in Settings.
*/
@Composable
internal fun CalendarsStep(
onCreateLocal: (name: String, color: Int, description: String?) -> Unit,
onSkip: () -> Unit,
createdName: String?,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
@@ -55,6 +62,17 @@ internal fun CalendarsStep(
// step is the first place anyone meets one.
var creating by rememberSaveable { mutableStateOf(false) }
if (createdName != null) {
CalendarCreated(
name = createdName,
onContinue = onFinished,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
return
}
if (creating) {
CalendarEditor(
sessionKey = 0,
@@ -132,3 +150,53 @@ internal fun CalendarsStep(
)
}
}
/** What the step shows once the calendar exists: that it does, and where the next one comes from. */
@Composable
private fun CalendarCreated(
name: String,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CheckCircle) },
actions = {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_continue),
style = MaterialTheme.typography.titleMedium,
)
}
},
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_title, name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_created_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
icon = Icons.Filled.Settings,
title = stringResource(R.string.onboarding_calendars_created_more_title),
body = stringResource(R.string.onboarding_calendars_created_more_body),
)
}
}
@@ -28,6 +28,7 @@ fun OnboardingSteps(
// Collected for every step, not just the two that draw a preview: starting
// it when the picker composes would leave that step blank as it slides in.
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
val createdCalendar by viewModel.createdCalendar.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
@@ -70,6 +71,8 @@ fun OnboardingSteps(
OnboardingStep.Calendars -> CalendarsStep(
onCreateLocal = viewModel::createLocalCalendar,
onSkip = viewModel::skipCalendars,
createdName = createdCalendar,
onFinished = viewModel::finishCalendars,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -92,22 +93,34 @@ class OnboardingViewModel @Inject constructor(
}
}
/**
* The calendar the step has just made, for as long as it is saying so.
* Non-null also holds the step open: the new calendar answers
* [calendarsApplies] with false, which would otherwise drop the step out
* from under the message that it worked (#287).
*/
private val _createdCalendar = MutableStateFlow<String?>(null)
val createdCalendar: StateFlow<String?> = _createdCalendar.asStateFlow()
/**
* 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)
}
}
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)
}
},
_createdCalendar,
) { applies, created -> if (created != null) null else applies }
/** The calendar step's own pair: whether it applies, and whether it is answered. */
private val calendarsFlags: Flow<CalendarsFlags> = combine(
@@ -236,21 +249,31 @@ class OnboardingViewModel @Inject constructor(
}
/**
* Make the device-only calendar the step's editor just described, and close
* the step.
* Make the device-only calendar the step's editor just described. The step
* then stays up on [createdCalendar] to confirm it and say where to make
* more. A provider that refused the insert will not succeed on a second
* press, so that closes the step rather than confirm a calendar that isn't
* there.
*/
fun createLocalCalendar(displayName: String, color: Int, description: String?) {
viewModelScope.launch {
runCatching {
val name = displayName.trim()
val created = runCatching {
repository.createLocalCalendar(
displayName = displayName,
displayName = name,
color = color,
description = description,
)
}
// Closed either way: a provider that refused the insert will not
// succeed on a second press, and the step is skippable regardless.
}.isSuccess
if (created) _createdCalendar.value = name else prefs.setOnboardingCalendarsDone()
}
}
/** Close the calendar step once its confirmation has been read. */
fun finishCalendars() {
viewModelScope.launch {
prefs.setOnboardingCalendarsDone()
_createdCalendar.value = null
}
}
+5
View File
@@ -307,6 +307,11 @@
<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_calendars_created_title">“%1$s” is ready</string>
<string name="onboarding_calendars_created_body">It lives on this device and new events go into it unless you pick another calendar.</string>
<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_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>