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 index d3abe645..4b8084c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt @@ -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), + ) + } +} 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 fb814e67..6cffde29 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 @@ -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, 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 26381d7f..ed6a65cd 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 @@ -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(null) + val createdCalendar: StateFlow = _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 = 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 = 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 = 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3461603b..fbba1b33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,6 +307,11 @@ 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. + “%1$s” is ready + It lives on this device and new events go into it unless you pick another calendar. + Room for more + Settings → Calendars makes as many as you need, and renames or recolours the ones you have. + Continue Choose folder and back up Not now What should open first?