From e6736b049a9bebc88853baf122e4684f669d6c94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 20:39:07 +0200 Subject: [PATCH 1/2] feat(settings): settings UX polish + Calendars manager redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: - Event-form field rows show each field's own icon (shared EventFormFieldVisuals, reused by the editor and settings) - Move "Add Quick Settings tile" to a top-level Settings hub row - Notifications: reliable-delivery + snooze moved above the per-calendar block, which now folds behind one expandable section Calendars manager: - Local and synced calendars now use one collapsible group card - Source-branded headers: each account shows its app's launcher icon (Google Calendar, DAVx5, …) loaded from PackageManager; local shows a device chip - Per-account toggle-all switch (CalendarsViewModel.setAccountDisabled) - Management action ("Add calendar" / "Manage in app") is a nested row inside the expanded group; removed the dead "Add account" row - A fully deactivated account dims its header, not just the switch - Group headers use the normal row colour; their options sit one tone darker (GroupedRow gains an optional container colour) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 319 ++++++++++++------ .../ui/calendars/CalendarsViewModel.kt | 13 + .../ui/common/EventFormFieldVisuals.kt | 44 +++ .../calendula/ui/common/GroupedList.kt | 9 +- .../calendula/ui/edit/EventEditScreen.kt | 29 +- .../calendula/ui/settings/SettingsScreen.kt | 213 +++++++----- app/src/main/res/values/strings.xml | 3 + 7 files changed, 410 insertions(+), 220 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt 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 db9dfde..5fe402f 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 @@ -6,11 +6,14 @@ import android.content.Intent import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -26,20 +29,21 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FileDownload -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -61,20 +65,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow @@ -82,7 +91,6 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.pastelize -import de.jeanlucmakiola.calendula.ui.common.positionOf import java.time.LocalDate /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ @@ -148,6 +156,7 @@ fun CalendarsScreen( onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, onSetDisabled = viewModel::setDisabled, + onSetAccountDisabled = viewModel::setAccountDisabled, ) } } @@ -166,9 +175,14 @@ private fun CalendarsList( onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, onSetDisabled: (Long, Boolean) -> Unit, + onSetAccountDisabled: (Collection, Boolean) -> Unit, ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + // Accounts the user has folded shut; empty = all expanded (keeps every + // calendar visible by default, the section is collapsible for tidiness). + var collapsedAccounts by remember { mutableStateOf(emptySet()) } + var localExpanded by remember { mutableStateOf(true) } val writeErrorText = stringResource(R.string.calendars_write_error) LaunchedEffect(error) { @@ -208,38 +222,55 @@ private fun CalendarsList( onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { - // Local (device-only) calendars — the calendars the app owns. The - // "Add calendar" entry closes the group as its final row. - SectionHeader(stringResource(R.string.calendars_local_header)) - if (local.isEmpty()) { - HintText(stringResource(R.string.calendars_local_empty)) - } + // What the per-calendar / per-account switches below actually do. HintText(stringResource(R.string.calendars_disable_hint)) - val localCount = local.size + 1 - local.forEachIndexed { index, calendar -> - val disabled = calendar.id in disabledIds - GroupedRow( - title = calendar.displayName, - summary = calendar.description, - position = positionOf(index, localCount), - dimmed = disabled, - leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, - trailing = { - EnableSwitch( - calendarName = calendar.displayName, - enabled = !disabled, - onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + + // Local (device-only) calendars — one collapsible group. The header's + // "+" adds a calendar; the switch enables/disables them all at once; + // tapping a calendar row opens its editor. + val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds } + CalendarGroup( + title = stringResource(R.string.calendars_local_header), + expanded = localExpanded, + headerDisabled = localDisabled, + leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } }, + manageIcon = Icons.Default.Add, + manageLabel = stringResource(R.string.calendars_add), + onManage = onAdd, + manageConnectsBelow = local.isNotEmpty(), + onToggleExpand = { localExpanded = !localExpanded }, + showToggleAll = local.isNotEmpty(), + allEnabled = local.none { it.id in disabledIds }, + toggleAllLabel = stringResource( + R.string.calendars_account_toggle_all_a11y, + stringResource(R.string.calendars_local_header), + ), + onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, + ) { + if (local.isEmpty()) { + HintText(stringResource(R.string.calendars_local_empty)) + } else { + local.forEachIndexed { index, calendar -> + val disabled = calendar.id in disabledIds + GroupedRow( + title = calendar.displayName, + summary = calendar.description, + position = if (index == local.lastIndex) Position.Bottom else Position.Middle, + container = MaterialTheme.colorScheme.surfaceContainerHighest, + dimmed = disabled, + leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, + trailing = { + EnableSwitch( + calendarName = calendar.displayName, + enabled = !disabled, + onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + ) + }, + onClick = { onEdit(calendar) }, ) - }, - onClick = { onEdit(calendar) }, - ) + } + } } - GroupedRow( - title = stringResource(R.string.calendars_add), - position = positionOf(local.size, localCount), - leading = { AddAvatar() }, - onClick = onAdd, - ) // Backup — local calendars have no sync, so a .ics export is their only // safety net. Offered only when there is something to back up. @@ -259,40 +290,60 @@ private fun CalendarsList( Spacer(Modifier.height(16.dp)) - // Synced calendars — read-only, grouped by account, each with a - // per-account "manage in source app" link. + // Synced calendars — read-only, grouped by account. Each account is a + // collapsible group whose header opens the source app (icon) and toggles + // all of its calendars at once (switch). SectionHeader(stringResource(R.string.calendars_synced_header)) HintText(stringResource(R.string.calendars_synced_hint)) synced .groupBy { it.accountName.ifBlank { it.accountType } } .forEach { (account, cals) -> - AccountHeader(account = account, accountType = cals.first().accountType) - cals.forEachIndexed { index, calendar -> - val disabled = calendar.id in disabledIds - GroupedRow( - title = calendar.displayName, - position = positionOf(index, cals.size), - dimmed = disabled, - leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, - trailing = { - EnableSwitch( - calendarName = calendar.displayName, - enabled = !disabled, - onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, - ) - }, - ) + val expanded = account !in collapsedAccounts + val accountType = cals.first().accountType + val accountDisabled = cals.all { it.id in disabledIds } + Spacer(Modifier.height(16.dp)) + CalendarGroup( + title = account, + expanded = expanded, + headerDisabled = accountDisabled, + leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } }, + manageIcon = Icons.AutoMirrored.Filled.OpenInNew, + manageLabel = stringResource(R.string.calendars_manage_account_a11y, account), + onManage = { + runCatching { context.startActivity(sourceAppIntent(context, accountType)) } + }, + manageConnectsBelow = true, + onToggleExpand = { + collapsedAccounts = if (expanded) { + collapsedAccounts + account + } else { + collapsedAccounts - account + } + }, + showToggleAll = true, + allEnabled = cals.none { it.id in disabledIds }, + toggleAllLabel = stringResource(R.string.calendars_account_toggle_all_a11y, account), + onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) }, + ) { + cals.forEachIndexed { index, calendar -> + val disabled = calendar.id in disabledIds + GroupedRow( + title = calendar.displayName, + position = if (index == cals.lastIndex) Position.Bottom else Position.Middle, + container = MaterialTheme.colorScheme.surfaceContainerHighest, + dimmed = disabled, + leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, + trailing = { + EnableSwitch( + calendarName = calendar.displayName, + enabled = !disabled, + onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + ) + }, + ) + } } } - Spacer(Modifier.height(8.dp)) - GroupedRow( - title = stringResource(R.string.calendars_add_account), - position = Position.Alone, - leading = { AddAvatar() }, - onClick = { - runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } - }, - ) } } @@ -498,32 +549,121 @@ private fun EditorCard( } } +/** + * One collapsible calendar group rendered as a connected card. The header row is + * the card's top (tap to expand/collapse): a [leading] source mark (the account + * app's logo, or a device chip for local), the group [title] and — when + * [showToggleAll] — a "toggle all" [Switch] that enables or disables every + * calendar in the group at once. + * + * When expanded, the body opens with a differentiated management row + * ([manageIcon] / [manageLabel] → [onManage]: add a calendar, or open the source + * app) above the plain calendar rows supplied by [body]; [manageConnectsBelow] + * is false when no calendar rows follow it, so it rounds off as the card's foot. + * + * An active group gets a tinted header; once [headerDisabled] (every calendar in + * the group switched off) the header drops to a plain, dimmed row so the whole + * group reads as off — not just its switch. + */ @Composable -private fun AccountHeader(account: String, accountType: String) { - val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, +private fun CalendarGroup( + title: String, + expanded: Boolean, + headerDisabled: Boolean, + leading: @Composable () -> Unit, + manageIcon: ImageVector, + manageLabel: String, + onManage: () -> Unit, + manageConnectsBelow: Boolean, + onToggleExpand: () -> Unit, + showToggleAll: Boolean, + allEnabled: Boolean, + toggleAllLabel: String, + onToggleAll: (Boolean) -> Unit, + body: @Composable ColumnScope.() -> Unit, +) { + GroupedRow( + title = title, + position = if (expanded) Position.Top else Position.Alone, + // The account header keeps the standard row colour; the options it reveals + // sit one tone darker, so the group reads as a header over nested content. + dimmed = headerDisabled, + leading = leading, + trailing = if (showToggleAll) { + { + Switch( + checked = allEnabled, + onCheckedChange = onToggleAll, + modifier = Modifier.semantics { contentDescription = toggleAllLabel }, + ) + } + } else { + null + }, + onClick = onToggleExpand, + ) + AnimatedVisibility( + visible = expanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), ) { - Text( - text = account, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - OutlinedButton(onClick = { - runCatching { context.startActivity(sourceAppIntent(context, accountType)) } - }) { - Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.calendars_manage_in_app)) + Column { + GroupedRow( + title = manageLabel, + position = if (manageConnectsBelow) Position.Middle else Position.Bottom, + container = MaterialTheme.colorScheme.surfaceContainerHighest, + dimmed = headerDisabled, + leading = { Icon(manageIcon, contentDescription = null) }, + onClick = onManage, + ) + body() } } } -/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */ +/** + * The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a + * round 40dp chip, so each synced account is recognisable at a glance. We load + * whatever app owns the account from [PackageManager] rather than bundling brand + * logos — always accurate, nothing to license. Falls back to a neutral cloud + * chip when no installed app resolves for the account. + */ +@Composable +private fun SourceLogo(accountType: String) { + val context = LocalContext.current + val logo = remember(accountType) { sourceAppLogo(context, accountType) } + if (logo != null) { + Image( + bitmap = logo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + } else { + LeadingAvatar(Icons.Default.Cloud) + } +} + +/** The launcher icon of the app backing [accountType], preferring the human-facing app. */ +private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { + val pm = context.packageManager + val candidates = buildList { + curatedSourcePackage(accountType)?.let { add(it) } + AccountManager.get(context).authenticatorTypes + .firstOrNull { it.type.equals(accountType, ignoreCase = true) } + ?.packageName + ?.let { add(it) } + } + for (pkg in candidates) { + val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() + if (bitmap != null) return bitmap.asImageBitmap() + } + return null +} + +/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */ @Composable private fun LeadingAvatar(icon: ImageVector) { Box( @@ -542,25 +682,6 @@ private fun LeadingAvatar(icon: ImageVector) { } } -/** Neutral circular chip with a "+" — the leading icon for add-actions. */ -@Composable -private fun AddAvatar() { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } -} - @Composable private fun SectionHeader(text: String) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 17451f7..2d1e1b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -123,6 +123,19 @@ class CalendarsViewModel @Inject constructor( } } + /** + * Enable or disable every calendar of one account in a single write — the + * "toggle all" affordance on an account header. Done as one set update so the + * per-calendar [setDisabled] calls can't race each other. + */ + fun setAccountDisabled(ids: Collection, disabled: Boolean) { + viewModelScope.launch { + val current = prefs.disabledCalendarIds.first() + val next = if (disabled) current + ids else current - ids.toSet() + if (next != current) prefs.setDisabledCalendarIds(next) + } + } + private inline fun write(crossinline block: suspend () -> Unit) { viewModelScope.launch { try { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt new file mode 100644 index 0000000..aa07543 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.ui.graphics.vector.ImageVector +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventFormField + +/** + * The shared label and icon for each optional [EventFormField]. Both the event + * editor (which shows these fields) and the settings screen (which toggles their + * defaults) draw from this one mapping, so a field reads the same wherever it + * appears. + */ +@StringRes +fun eventFormFieldLabel(field: EventFormField): Int = when (field) { + EventFormField.Location -> R.string.event_detail_location + EventFormField.Description -> R.string.event_detail_description + EventFormField.Reminders -> R.string.event_detail_reminders + EventFormField.Recurrence -> R.string.event_detail_recurrence + EventFormField.Availability -> R.string.event_edit_availability + EventFormField.Visibility -> R.string.event_edit_visibility + EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees +} + +fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { + EventFormField.Location -> Icons.Default.Place + EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Reminders -> Icons.Default.Notifications + EventFormField.Recurrence -> Icons.Default.Repeat + EventFormField.Availability -> Icons.Default.EventAvailable + EventFormField.Visibility -> Icons.Default.Lock + EventFormField.Color -> Icons.Default.Palette + EventFormField.Attendees -> Icons.Default.People +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index 7479753..c183aab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -140,6 +140,7 @@ fun GroupedRow( summary: String? = null, selected: Boolean = false, dimmed: Boolean = false, + container: Color? = null, minHeight: Dp = 72.dp, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, @@ -198,10 +199,10 @@ fun GroupedRow( .fillMaxWidth() .padding(horizontal = 16.dp) .then(gap) - val containerColor = if (selected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh + val containerColor = when { + selected -> MaterialTheme.colorScheme.secondaryContainer + container != null -> container + else -> MaterialTheme.colorScheme.surfaceContainerHigh } if (onClick != null) { Surface( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 853edfe..22d5ac6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place @@ -123,6 +122,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard @@ -1019,9 +1020,9 @@ private fun FieldPickerDialog( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { hiddenFields.forEach { field -> OptionCard( - label = stringResource(fieldLabel(field)), + label = stringResource(eventFormFieldLabel(field)), onClick = { onSelect(field) }, - icon = fieldIcon(field), + icon = eventFormFieldIcon(field), ) } } @@ -1638,28 +1639,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? = } -private fun fieldLabel(field: EventFormField): Int = when (field) { - EventFormField.Location -> R.string.event_detail_location - EventFormField.Description -> R.string.event_detail_description - EventFormField.Reminders -> R.string.event_detail_reminders - EventFormField.Recurrence -> R.string.event_detail_recurrence - EventFormField.Availability -> R.string.event_edit_availability - EventFormField.Visibility -> R.string.event_edit_visibility - EventFormField.Color -> R.string.event_edit_color - EventFormField.Attendees -> R.string.event_edit_attendees -} - -private fun fieldIcon(field: EventFormField): ImageVector = when (field) { - EventFormField.Location -> Icons.Default.Place - EventFormField.Description -> Icons.AutoMirrored.Filled.Notes - EventFormField.Reminders -> Icons.Default.Notifications - EventFormField.Recurrence -> Icons.Default.Repeat - EventFormField.Availability -> Icons.Default.EventAvailable - EventFormField.Visibility -> Icons.Default.Lock - EventFormField.Color -> Icons.Default.Palette - EventFormField.Attendees -> Icons.Default.People -} - /** * Visibility selector: one card per level, each with its own icon; the * current level is highlighted. Tap picks and closes. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 5dc6ef8..dab5958 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -109,6 +110,8 @@ import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime import java.time.format.TextStyle as JavaTextStyle @@ -220,6 +223,12 @@ private fun SettingsHub( onClick = onManageCalendars, ) LanguageRow(position = Position.Middle) + // One-tap add of the "New event" Quick Settings tile. The system prompt + // is API 33+; on older versions the tile is still addable manually from + // the QS editor, so the row simply doesn't appear here. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + QuickSettingsTileRow(position = Position.Middle) + } ReportProblemRow(position = Position.Bottom) AppVersionText() @@ -261,6 +270,24 @@ private fun ReportProblemRow(position: Position) { } } +/** + * Asks the system to add the "New event" Quick Settings tile (API 33+). A + * top-level action row, like [ReportProblemRow] — the tile is a system-surface + * shortcut, not part of any one settings category. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +private fun QuickSettingsTileRow(position: Position) { + val context = LocalContext.current + GroupedRow( + title = stringResource(R.string.settings_qs_tile), + summary = stringResource(R.string.settings_qs_tile_hint), + position = position, + leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) }, + onClick = { requestAddQsTile(context) }, + ) +} + @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current @@ -612,8 +639,17 @@ private fun EventFormScreen( fields.forEachIndexed { index, field -> val checked = field in state.defaultFormFields GroupedRow( - title = stringResource(formFieldLabel(field)), + title = stringResource(eventFormFieldLabel(field)), position = positionOf(index, fields.size), + // Same icon the field carries in the new-event form, so a toggle + // is easy to match to the field it controls. + leading = { + Icon( + imageVector = eventFormFieldIcon(field), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, trailing = { Switch( checked = checked, @@ -644,20 +680,6 @@ private fun EventFormScreen( ) }, ) - - // One-tap add of the "New event" Quick Settings tile. The system prompt - // is API 33+; on older versions the tile is still addable manually from - // the QS editor, so the row simply doesn't appear there. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Spacer(Modifier.height(24.dp)) - val context = LocalContext.current - GroupedRow( - title = stringResource(R.string.settings_qs_tile), - summary = stringResource(R.string.settings_qs_tile_hint), - position = Position.Alone, - onClick = { requestAddQsTile(context) }, - ) - } } } @@ -709,6 +731,7 @@ private fun NotificationsScreen( var showAllDayReminderTime by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) } var overrideDialog by remember { mutableStateOf(null) } + var calendarSectionExpanded by remember { mutableStateOf(false) } var expandedCalendars by remember { mutableStateOf(emptySet()) } CollapsingScaffold( @@ -746,69 +769,11 @@ private fun NotificationsScreen( onClick = { showAllDayReminderTime = true }, ) - // Per-calendar overrides: each writable calendar may keep, drop, or - // replace the global default — separately for timed and all-day events. - if (state.writableCalendars.isNotEmpty()) { - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.settings_calendar_reminders_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - state.writableCalendars.forEach { calendar -> - Spacer(Modifier.height(16.dp)) - val expanded = calendar.id in expandedCalendars - // Calendar card; tapping expands it into a grouped list of three - // (the card + the timed and all-day override rows). - GroupedRow( - title = calendar.displayName, - position = if (expanded) Position.Top else Position.Alone, - leading = { CalendarColorChip(calendar.color) }, - trailing = { - Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = { - expandedCalendars = if (expanded) { - expandedCalendars - calendar.id - } else { - expandedCalendars + calendar.id - } - }, - ) - AnimatedVisibility( - visible = expanded, - enter = calendarExpandEnter(), - exit = calendarCollapseExit(), - ) { - Column { - val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) - GroupedRow( - title = stringResource(R.string.settings_default_reminder), - summary = calendarOverrideSummary(timed, state.defaultReminderMinutes), - position = Position.Middle, - onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) }, - ) - val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id) - GroupedRow( - title = stringResource(R.string.settings_default_reminder_allday), - summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes), - position = Position.Bottom, - onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) }, - ) - } - } - } - } - - // Delivery reliability: Android's battery optimisation can delay or drop - // the calendar provider's reminder broadcast. A soft, optional exemption - // (system-settings deep-link, no special permission) improves on-time - // delivery; shown as live status, reversible by the user at any time. + // Delivery reliability + snooze: both are global reminder-delivery + // settings, so they sit with the defaults above rather than below the + // long per-calendar list. Reliability is a soft, optional battery- + // optimisation exemption (system-settings deep-link, no special + // permission); shown as live status, reversible at any time. Spacer(Modifier.height(24.dp)) val batteryExempt = rememberBatteryOptimizationExempt() GroupedRow( @@ -818,7 +783,7 @@ private fun NotificationsScreen( } else { stringResource(R.string.settings_reliable_delivery_hint) }, - position = Position.Alone, + position = Position.Top, trailing = if (batteryExempt) { { Icon( @@ -834,13 +799,88 @@ private fun NotificationsScreen( ) // Snooze: how long the notification's "Snooze" action defers a reminder. - Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_snooze_duration), summary = snoozeDurationLabel(state.snoozeMinutes), - position = Position.Alone, + position = Position.Bottom, onClick = { showSnooze = true }, ) + + // Per-calendar overrides: the whole section folds behind one header to + // keep the screen tidy. Expanded, each writable calendar gets its own + // expandable card that may keep, drop, or replace the global default — + // separately for timed and all-day events. + if (state.writableCalendars.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_calendar_reminders_title), + summary = stringResource(R.string.settings_calendar_reminders_hint), + position = Position.Alone, + trailing = { + Icon( + imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { calendarSectionExpanded = !calendarSectionExpanded }, + ) + AnimatedVisibility( + visible = calendarSectionExpanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { + Column { + state.writableCalendars.forEach { calendar -> + Spacer(Modifier.height(16.dp)) + val expanded = calendar.id in expandedCalendars + // Calendar card; tapping expands it into a grouped list + // of three (the card + the timed and all-day rows). + GroupedRow( + title = calendar.displayName, + position = if (expanded) Position.Top else Position.Alone, + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { + expandedCalendars = if (expanded) { + expandedCalendars - calendar.id + } else { + expandedCalendars + calendar.id + } + }, + ) + AnimatedVisibility( + visible = expanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { + Column { + val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = calendarOverrideSummary(timed, state.defaultReminderMinutes), + position = Position.Middle, + onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) }, + ) + val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id) + GroupedRow( + title = stringResource(R.string.settings_default_reminder_allday), + summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes), + position = Position.Bottom, + onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) }, + ) + } + } + } + } + } + } } if (showSnooze) { @@ -1061,17 +1101,6 @@ private fun openUrl(context: Context, url: String) { runCatching { context.startActivity(intent) } } -private fun formFieldLabel(field: EventFormField): Int = when (field) { - EventFormField.Location -> R.string.event_detail_location - EventFormField.Description -> R.string.event_detail_description - EventFormField.Reminders -> R.string.event_detail_reminders - EventFormField.Recurrence -> R.string.event_detail_recurrence - EventFormField.Availability -> R.string.event_edit_availability - EventFormField.Visibility -> R.string.event_edit_visibility - EventFormField.Color -> R.string.event_edit_color - EventFormField.Attendees -> R.string.event_edit_attendees -} - @Composable private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60c1872..f12a4aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -309,6 +309,7 @@ Amount Custom (%1$s) Set + Per-calendar reminders Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own. Default (%1$s) Reliable delivery @@ -343,9 +344,11 @@ Add calendar Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime. Show \"%1$s\" in the app + Show all calendars from %1$s in the app Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage + Manage %1$s in its app Add account New calendar Edit calendar -- 2.49.1 From 614f4f2d75291d0e3e048bf3c48ba1ae76ae916c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 21:27:20 +0200 Subject: [PATCH 2/2] feat(calendars): account header overflow menu + restyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-account toggle switch and nested manage row with a single trailing overflow (⋮) menu holding both account-level actions: - "Enable all" / "Disable all" — toggle every calendar in the group - "Manage in app" (synced) / "Add calendar" (local) The dropdown is styled to fit: rounded corners, a distinct floating surface (surfaceContainerLowest + lifted shadow) so it stands clear of the cards, and a divider separating the two actions. Shortened the manage label to "Manage in app" and dropped the now-unused account a11y strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 144 ++++++++++++------ app/src/main/res/values/strings.xml | 7 +- 2 files changed, 105 insertions(+), 46 deletions(-) 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 5fe402f..6ae8fba 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 @@ -36,11 +36,17 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -232,19 +238,15 @@ private fun CalendarsList( CalendarGroup( title = stringResource(R.string.calendars_local_header), expanded = localExpanded, + bodyHasRows = local.isNotEmpty(), headerDisabled = localDisabled, leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } }, manageIcon = Icons.Default.Add, manageLabel = stringResource(R.string.calendars_add), onManage = onAdd, - manageConnectsBelow = local.isNotEmpty(), onToggleExpand = { localExpanded = !localExpanded }, showToggleAll = local.isNotEmpty(), allEnabled = local.none { it.id in disabledIds }, - toggleAllLabel = stringResource( - R.string.calendars_account_toggle_all_a11y, - stringResource(R.string.calendars_local_header), - ), onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, ) { if (local.isEmpty()) { @@ -305,14 +307,14 @@ private fun CalendarsList( CalendarGroup( title = account, expanded = expanded, + bodyHasRows = true, headerDisabled = accountDisabled, leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } }, manageIcon = Icons.AutoMirrored.Filled.OpenInNew, - manageLabel = stringResource(R.string.calendars_manage_account_a11y, account), + manageLabel = stringResource(R.string.calendars_manage_in_app), onManage = { runCatching { context.startActivity(sourceAppIntent(context, accountType)) } }, - manageConnectsBelow = true, onToggleExpand = { collapsedAccounts = if (expanded) { collapsedAccounts + account @@ -322,7 +324,6 @@ private fun CalendarsList( }, showToggleAll = true, allEnabled = cals.none { it.id in disabledIds }, - toggleAllLabel = stringResource(R.string.calendars_account_toggle_all_a11y, account), onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) }, ) { cals.forEachIndexed { index, calendar -> @@ -552,53 +553,50 @@ private fun EditorCard( /** * One collapsible calendar group rendered as a connected card. The header row is * the card's top (tap to expand/collapse): a [leading] source mark (the account - * app's logo, or a device chip for local), the group [title] and — when - * [showToggleAll] — a "toggle all" [Switch] that enables or disables every - * calendar in the group at once. + * app's logo, or a device chip for local), the group [title] and a trailing + * overflow (⋮) menu holding the account-level actions — enable/disable every + * calendar at once (when [showToggleAll]) and the manage/add action + * ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as + * the rows below via [body]. * - * When expanded, the body opens with a differentiated management row - * ([manageIcon] / [manageLabel] → [onManage]: add a calendar, or open the source - * app) above the plain calendar rows supplied by [body]; [manageConnectsBelow] - * is false when no calendar rows follow it, so it rounds off as the card's foot. - * - * An active group gets a tinted header; once [headerDisabled] (every calendar in - * the group switched off) the header drops to a plain, dimmed row so the whole - * group reads as off — not just its switch. + * The header keeps the standard row colour; the calendars it reveals sit one tone + * darker, so the group reads as a header over nested content. Once [headerDisabled] + * (every calendar switched off) the header fades to the disabled emphasis so the + * whole group reads as off — not just via the menu. [bodyHasRows] is false when no + * calendar rows follow (e.g. an empty local group), so the header stays a + * standalone card rather than a top edge with nothing beneath it. */ @Composable private fun CalendarGroup( title: String, expanded: Boolean, + bodyHasRows: Boolean, headerDisabled: Boolean, leading: @Composable () -> Unit, manageIcon: ImageVector, manageLabel: String, onManage: () -> Unit, - manageConnectsBelow: Boolean, onToggleExpand: () -> Unit, showToggleAll: Boolean, allEnabled: Boolean, - toggleAllLabel: String, onToggleAll: (Boolean) -> Unit, body: @Composable ColumnScope.() -> Unit, ) { GroupedRow( title = title, - position = if (expanded) Position.Top else Position.Alone, - // The account header keeps the standard row colour; the options it reveals - // sit one tone darker, so the group reads as a header over nested content. + position = if (expanded && bodyHasRows) Position.Top else Position.Alone, dimmed = headerDisabled, leading = leading, - trailing = if (showToggleAll) { - { - Switch( - checked = allEnabled, - onCheckedChange = onToggleAll, - modifier = Modifier.semantics { contentDescription = toggleAllLabel }, - ) - } - } else { - null + trailing = { + CalendarGroupMenu( + title = title, + showToggleAll = showToggleAll, + allEnabled = allEnabled, + onToggleAll = onToggleAll, + manageIcon = manageIcon, + manageLabel = manageLabel, + onManage = onManage, + ) }, onClick = onToggleExpand, ) @@ -607,16 +605,76 @@ private fun CalendarGroup( enter = calendarExpandEnter(), exit = calendarCollapseExit(), ) { - Column { - GroupedRow( - title = manageLabel, - position = if (manageConnectsBelow) Position.Middle else Position.Bottom, - container = MaterialTheme.colorScheme.surfaceContainerHighest, - dimmed = headerDisabled, - leading = { Icon(manageIcon, contentDescription = null) }, - onClick = onManage, + Column(content = body) + } +} + +/** + * The account header's overflow (⋮) menu: the two account-level actions that + * don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the + * manage/add action. A rounded, tonal dropdown matching the app's surfaces, with + * the two actions divided for clear separation. + */ +@Composable +private fun CalendarGroupMenu( + title: String, + showToggleAll: Boolean, + allEnabled: Boolean, + onToggleAll: (Boolean) -> Unit, + manageIcon: ImageVector, + manageLabel: String, + onManage: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.calendars_account_menu_a11y, title), + ) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + shape = RoundedCornerShape(20.dp), + // A distinct tone + a lifted shadow so the menu reads as floating + // above the cards (which sit at surfaceContainerHigh) rather than + // blending into them. + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 0.dp, + shadowElevation = 6.dp, + ) { + if (showToggleAll) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (allEnabled) R.string.calendars_disable_all + else R.string.calendars_enable_all, + ), + ) + }, + leadingIcon = { + Icon( + if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null, + ) + }, + onClick = { + open = false + onToggleAll(!allEnabled) + }, + ) + HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) + } + DropdownMenuItem( + text = { Text(manageLabel) }, + leadingIcon = { Icon(manageIcon, contentDescription = null) }, + onClick = { + open = false + onManage() + }, ) - body() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f12a4aa..fdc17d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -344,11 +344,12 @@ Add calendar Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime. Show \"%1$s\" in the app - Show all calendars from %1$s in the app Synced calendars These come from accounts on your device. Create and edit them in their own app. - Manage - Manage %1$s in its app + Manage in app + More options for %1$s + Enable all + Disable all Add account New calendar Edit calendar -- 2.49.1