Confirm the calendar the setup step made (#287)
This commit is contained in:
@@ -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,
|
||||
|
||||
+40
-17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user