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/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 9d5b84c..a3e2e55 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 @@ -97,11 +97,23 @@ 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.compose.foundation.selection.selectable +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.graphics.luminance +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,7 +355,13 @@ 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 internal fun CalendarEditor( sessionKey: Int, @@ -360,121 +378,48 @@ internal 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() } + // A new calendar opens with the keyboard up: naming it is the whole task. + LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() } - 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, + ) + + 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_hint)) + 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 +430,21 @@ internal 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 +460,178 @@ internal 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 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), + 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 = eventAccent(swatch, dark, soften), + 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, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = stringResource(R.string.calendars_color_label) + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + // selectable, not clickable: the swatch carries its chosen state in + // semantics, so the check is decoration rather than 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 = if (fill.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(22.dp), + ) + } + } +} + +/** 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 +674,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