diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt index 58b9af1..8860fff 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt @@ -1,22 +1,35 @@ package de.jeanlucmakiola.calendula.data.calendar /** - * Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / - * `EVENT_COLOR`. The named entries exist for callers that need one specific - * hue (the managed special-dates calendars), so they can't drift from the - * swatches offered in the colour picker. + * The colours offered when creating or editing a calendar; ARGB ints for a raw + * `CALENDAR_COLOR` / `EVENT_COLOR`. + * + * Hues rather than final fills: every surface that draws a calendar colour runs + * it through the app's own tone pass first, so these are chosen to stay + * distinguishable after it. Shared with Agendula, whose list palette these are — + * the two apps are the same family and a calendar and a task list picked from + * different-looking sets read as different products. + * + * The named entries exist for callers that need one specific hue (the managed + * special-dates calendars), so they can't drift from the swatches on offer. */ object CalendarColorPalette { - val Red = 0xFFD50000.toInt() - val Orange = 0xFFE67C00.toInt() - val Amber = 0xFFF6BF26.toInt() - val Green = 0xFF33B679.toInt() - val DarkGreen = 0xFF0B8043.toInt() - val Blue = 0xFF039BE5.toInt() - val Indigo = 0xFF3F51B5.toInt() - val Purple = 0xFF8E24AA.toInt() - val Graphite = 0xFF616161.toInt() + val Mauve = 0xFF7A5C6B.toInt() + val Red = 0xFFD7484A.toInt() + val Orange = 0xFFE8743B.toInt() + val Amber = 0xFFE0A32E.toInt() + val Olive = 0xFF7CA83E.toInt() + val Green = 0xFF35A06A.toInt() + val Teal = 0xFF19938C.toInt() + val Cyan = 0xFF2A9BC4.toInt() + val Blue = 0xFF3C74C8.toInt() + val Indigo = 0xFF6A5CC0.toInt() + val Purple = 0xFF9455B8.toInt() + val Pink = 0xFFC94F8E.toInt() - /** The full palette, in swatch-row order. */ - val all: List = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite) + /** The full palette, in swatch-grid order. */ + val all: List = listOf( + Mauve, Red, Orange, Amber, Olive, Green, + Teal, Cyan, Blue, Indigo, Purple, Pink, + ) } 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/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 8886ad2..58e1075 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage @@ -97,11 +98,25 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.predictiveBack -import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.heightIn +import androidx.annotation.StringRes +import androidx.compose.foundation.border +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.Check +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedSurface /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ private const val NEW_CALENDAR_ID = Long.MIN_VALUE @@ -343,9 +358,15 @@ private fun CalendarsList( } -@OptIn(ExperimentalMaterial3Api::class) +private const val SWATCHES_PER_ROW = 6 + +/** + * Create or edit a local calendar: a name field over the palette of calendar + * colours, on the family's full-screen sheet with the commit in its title bar. + * The same sheet Agendula edits a task list in, so the two read as one product. + */ @Composable -private fun CalendarEditor( +internal fun CalendarEditor( sessionKey: Int, isNew: Boolean, initialName: String, @@ -360,121 +381,50 @@ private fun CalendarEditor( var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var confirmDelete by remember { mutableStateOf(false) } - val dark = isSystemInDarkTheme() - val soften = LocalSoftenColors.current + val focusRequester = remember { FocusRequester() } - Scaffold( - modifier = Modifier - .predictiveBack(onBack = onClose) - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - topBar = { - TopAppBar( - title = { - Text( - stringResource( - if (isNew) R.string.calendars_new_title - else R.string.calendars_edit_title, - ), - ) - }, - navigationIcon = { - IconButton(onClick = onClose) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.event_edit_close), - ) - } - }, - actions = { - if (!isNew) { - // Disabled rather than hidden while the special-dates - // sync owns this calendar; the card below says why. - IconButton( - onClick = { confirmDelete = true }, - enabled = !deleteLocked, - ) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.event_detail_delete), - tint = if (deleteLocked) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } else { - MaterialTheme.colorScheme.error - }, - ) - } - } - // Filled save button, matching the event editor's top bar. - Button( - onClick = { - onSave(name.trim(), color, description.trim().ifEmpty { null }) - }, - enabled = name.isNotBlank(), - modifier = Modifier.padding(end = 12.dp), - ) { - Text(stringResource(R.string.event_edit_save)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) + val valid = name.isNotBlank() + val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) } + + FullScreenPicker( + title = stringResource( + if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title, + ), + onDismiss = onClose, + predictiveBack = true, + actions = { + Button( + onClick = commit, + enabled = valid, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.event_edit_save)) } }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (deleteLocked) { - EditorCard( - icon = Icons.Default.Info, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - iconAtTop = true, - ) { - Text( - text = stringResource(R.string.calendars_managed_delete_locked), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) { - InlineTextField( - value = name, - onValueChange = { name = it }, - placeholder = stringResource(R.string.calendars_name_label), - textStyle = MaterialTheme.typography.titleLarge, - capitalization = KeyboardCapitalization.Sentences, - ) - } - EditorCard( - icon = Icons.Default.Palette, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - iconAtTop = true, - ) { - Text( - text = stringResource(R.string.calendars_color_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - ColorSwatchRow( - colors = CalendarColorPalette.all, - selected = color, - onSelect = { color = it }, - dark = dark, - ) - } - EditorCard( - icon = Icons.AutoMirrored.Filled.Notes, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - iconAtTop = true, - ) { + ) { + if (deleteLocked) { + ManagedNote() + Spacer(Modifier.height(20.dp)) + } + + CalendarNameField( + name = name, + color = color, + focusRequester = focusRequester, + onNameChange = { name = it }, + onImeAction = commit, + ) + // Inside the picker: it is a Dialog, so a request made from the caller's + // composition can run before this field's node exists. + // A new calendar opens with the keyboard up: naming it is the whole task. + LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() } + + Spacer(Modifier.height(20.dp)) + EditorSectionLabel(stringResource(R.string.calendars_color_label)) + CalendarColorGrid(selected = color, onSelect = { color = it }) + + Spacer(Modifier.height(20.dp)) + EditorSectionLabel(stringResource(R.string.calendars_description_label)) + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) { InlineTextField( value = description, onValueChange = { description = it }, @@ -485,20 +435,21 @@ private fun CalendarEditor( ) } } + + if (!isNew && !deleteLocked) { + Spacer(Modifier.height(24.dp)) + DeleteCalendarRow(onClick = { confirmDelete = true }) + } + Spacer(Modifier.height(24.dp)) } if (confirmDelete) { AlertDialog( onDismissRequest = { confirmDelete = false }, title = { Text(stringResource(R.string.calendars_delete_confirm_title)) }, - text = { - Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) - }, + text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) }, confirmButton = { - TextButton(onClick = { - confirmDelete = false - onDelete() - }) { + TextButton(onClick = { confirmDelete = false; onDelete() }) { Text( stringResource(R.string.event_detail_delete), color = MaterialTheme.colorScheme.error, @@ -514,6 +465,205 @@ private fun CalendarEditor( } } +/** The name, with the chosen colour beside it so the two read as one thing. */ +@Composable +private fun CalendarNameField( + name: String, + color: Int, + focusRequester: FocusRequester, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.CalendarMonth, + contentDescription = null, + tint = eventAccent(color, dark, soften), + modifier = Modifier.size(22.dp), + ) + } + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.calendars_name_label), + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} + +/** + * The palette as two rows of round swatches; the chosen one carries a check. + * + * Drawn through the app's own tone pass rather than raw, so a swatch is the + * colour the calendar's events will actually appear in — the user's "soften + * colours" preference included. + */ +@Composable +private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 16.dp) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { swatch -> + ColorSwatch( + fill = eventFill(swatch, dark, soften), + label = stringResource(swatchLabel(swatch)), + selected = swatch == selected, + onClick = { onSelect(swatch) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a short final row's swatches the size a full row's + // are, rather than stretching them across the width. + repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun ColorSwatch( + fill: Color, + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + .then( + if (selected) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ) + // selectable, not clickable: the swatch carries its chosen state in + // semantics too, so the check is not the only cue. + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = eventInk(fill, alpha = 0.7f), + modifier = Modifier.size(22.dp), + ) + } + } +} + +/** Names the swatch for a screen reader — twelve circles are otherwise one label. */ +@StringRes +private fun swatchLabel(argb: Int): Int = when (argb) { + CalendarColorPalette.Mauve -> R.string.color_name_mauve + CalendarColorPalette.Red -> R.string.color_name_red + CalendarColorPalette.Orange -> R.string.color_name_orange + CalendarColorPalette.Amber -> R.string.color_name_amber + CalendarColorPalette.Olive -> R.string.color_name_olive + CalendarColorPalette.Green -> R.string.color_name_green + CalendarColorPalette.Teal -> R.string.color_name_teal + CalendarColorPalette.Cyan -> R.string.color_name_cyan + CalendarColorPalette.Blue -> R.string.color_name_blue + CalendarColorPalette.Indigo -> R.string.color_name_indigo + CalendarColorPalette.Purple -> R.string.color_name_purple + else -> R.string.color_name_pink +} + +/** Said in the sheet rather than on a disabled button: the sync owns this one. */ +@Composable +private fun ManagedNote() { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(Icons.Default.Info, contentDescription = null) + Text( + text = stringResource(R.string.calendars_managed_delete_locked), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DeleteCalendarRow(onClick: () -> Unit) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.event_detail_delete), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +@Composable +private fun EditorSectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp), + ) +} + /** * The row's supporting line: the states that make this calendar behave unlike a * plain writable one (#76), then its own description. @@ -556,36 +706,6 @@ private fun EnableSwitch( private fun dimIf(disabled: Boolean): Modifier = if (disabled) Modifier.alpha(0.38f) else Modifier -/** Tonal field card matching the event editor's design (icon + content). */ -@Composable -private fun EditorCard( - icon: ImageVector, - iconTint: Color, - iconAtTop: Boolean = false, - content: @Composable () -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint, - modifier = Modifier - .padding(top = if (iconAtTop) 2.dp else 0.dp) - .size(24.dp), - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { content() } - } - } -} /** * One collapsible calendar group rendered as a connected card. The header row is 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..97984eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt @@ -0,0 +1,224 @@ +package de.jeanlucmakiola.calendula.ui.onboarding + +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.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 +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.data.calendar.CalendarColorPalette +import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor +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. + * + * 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. + * + * [creation] switches the step to the outcome of its own create: confirming + * the calendar — the one place to say that more of them live in Settings — or + * reporting that the provider refused it. + */ +@Composable +internal fun CalendarsStep( + onCreateLocal: (name: String, color: Int, description: String?) -> Unit, + onSkip: () -> Unit, + creation: CalendarCreation?, + onFinished: () -> Unit, + onClearCreation: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + val context = LocalContext.current + // The same editor Settings opens, rather than a calendar conjured out of + // nowhere: "create a calendar" should let you name and colour it, and the + // step is the first place anyone meets one. + var creating by rememberSaveable { mutableStateOf(false) } + // The insert is a suspend write: the editor stays up until it lands, so the + // step can't flash its "create" button back for a second press. + var submitted by rememberSaveable(creating) { mutableStateOf(false) } + LaunchedEffect(creation) { if (creation is CalendarCreation.Failed) creating = false } + + if (creation is CalendarCreation.Created) { + CalendarCreated( + name = creation.name, + onContinue = onFinished, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) + return + } + + if (creating) { + CalendarEditor( + sessionKey = 0, + isNew = true, + // Empty, not a suggestion: a name already in the field reads as one + // the app has chosen, and the first thing to do is clear it. + initialName = "", + initialColor = CalendarColorPalette.all.first(), + initialDescription = "", + onSave = { name, color, description -> + if (!submitted) { + submitted = true + onCreateLocal(name, color, description) + } + }, + onDelete = {}, + onClose = { creating = false }, + ) + return + } + + OnboardingScaffold( + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.lg, + hero = { IconHero(Icons.Filled.CalendarMonth) }, + actions = { + Button( + onClick = { + onClearCreation() + creating = true + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_calendars_local_button), + style = MaterialTheme.typography.titleMedium, + ) + } + OutlinedButton( + onClick = { openUrl(context, DAVX5_URL) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + 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, + ) + + if (creation is CalendarCreation.Failed) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_calendars_create_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + 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), + ) + } +} + +/** 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/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..f0bb69a 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 creation by viewModel.creation.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 @@ -67,6 +68,16 @@ fun OnboardingSteps( modifier = modifier, progress = progress, ) + OnboardingStep.Calendars -> CalendarsStep( + onCreateLocal = viewModel::createLocalCalendar, + onSkip = viewModel::skipCalendars, + creation = creation, + onFinished = viewModel::finishCalendars, + onClearCreation = viewModel::clearCalendarCreation, + 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..9699eb7 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,6 +93,46 @@ class OnboardingViewModel @Inject constructor( } } + /** How the step's own create attempt ended, while it is still saying so (#287). */ + private val _creation = MutableStateFlow(null) + val creation: StateFlow = _creation.asStateFlow() + + /** + * Whether the step made a calendar here. Latched for the session and never + * cleared: the new calendar answers [calendarsApplies] with false, which + * would drop the step out of the plan the moment it is used — first from + * under the message that it worked, and then from the step count, which + * would fall by one just as the user pressed Continue. + */ + private val calendarCreatedHere = MutableStateFlow(false) + + /** + * 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 = 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) + } + }, + calendarCreatedHere, + ) { applies, latched -> if (latched) null else applies } + + /** 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 +151,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 +167,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 +235,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 +252,53 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { prefs.setOnboardingBackupDone() } } + /** + * Make the device-only calendar the step's editor just described. The step + * stays up on [creation] either way — to confirm the calendar and say where + * to make more, or to say the provider refused it. Answering the step for + * them would leave them where the step exists to stop them being: past it, + * with nowhere to put an event. + */ + fun createLocalCalendar(displayName: String, color: Int, description: String?) { + viewModelScope.launch { + val name = displayName.trim() + _creation.value = runCatching { + repository.createLocalCalendar( + displayName = name, + color = color, + description = description, + ) + }.fold( + onSuccess = { + calendarCreatedHere.value = true + CalendarCreation.Created(name) + }, + onFailure = { CalendarCreation.Failed }, + ) + } + } + + /** Close the calendar step once its confirmation has been read. */ + fun finishCalendars() { + viewModelScope.launch { + prefs.setOnboardingCalendarsDone() + _creation.value = null + } + } + + /** Drop the failed attempt so the step offers itself again. */ + fun clearCalendarCreation() { + _creation.value = null + } + + /** + * 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 +392,15 @@ private data class OnboardingFlags( val monthStyleDone: Boolean, ) +/** How the calendar step's create attempt ended (#287). */ +sealed interface CalendarCreation { + data class Created(val name: String) : CalendarCreation + data object Failed : CalendarCreation +} + +/** 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..94d1118 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -298,6 +298,21 @@ 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. + Create a calendar on this device + Sync with a 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. + “%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 + Couldn\'t create the calendar. Your phone\'s calendar storage refused it. Choose folder and back up Not now What should open first? @@ -673,7 +688,20 @@ Edit calendar Name Color + Description Add a description + Mauve + Red + Orange + Amber + Olive + Green + Teal + Cyan + Blue + Indigo + Purple + Pink Delete calendar? \"%1$s\" and all of its events will be permanently removed from this device. Couldn\'t save the change. 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) + } }