Merge pull request 'feat(settings): settings UX polish + Calendars manager redesign' (!46) from feat/settings-improvements into release/v2.11.0

Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
2026-06-27 19:47:43 +00:00
7 changed files with 470 additions and 221 deletions

View File

@@ -6,11 +6,14 @@ import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -26,20 +29,27 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes 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.Add
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close 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.Delete
import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -61,20 +71,25 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip 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.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
@@ -82,7 +97,6 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.LocalDate import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -148,6 +162,7 @@ fun CalendarsScreen(
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled, onSetDisabled = viewModel::setDisabled,
onSetAccountDisabled = viewModel::setAccountDisabled,
) )
} }
} }
@@ -166,9 +181,14 @@ private fun CalendarsList(
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit, onSetDisabled: (Long, Boolean) -> Unit,
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } 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<String>()) }
var localExpanded by remember { mutableStateOf(true) }
val writeErrorText = stringResource(R.string.calendars_write_error) val writeErrorText = stringResource(R.string.calendars_write_error)
LaunchedEffect(error) { LaunchedEffect(error) {
@@ -208,20 +228,37 @@ private fun CalendarsList(
onBack = onBack, onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { ) {
// Local (device-only) calendars — the calendars the app owns. The // What the per-calendar / per-account switches below actually do.
// "Add calendar" entry closes the group as its final row. HintText(stringResource(R.string.calendars_disable_hint))
SectionHeader(stringResource(R.string.calendars_local_header))
// 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,
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,
onToggleExpand = { localExpanded = !localExpanded },
showToggleAll = local.isNotEmpty(),
allEnabled = local.none { it.id in disabledIds },
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
) {
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} } else {
HintText(stringResource(R.string.calendars_disable_hint))
val localCount = local.size + 1
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
summary = calendar.description, summary = calendar.description,
position = positionOf(index, localCount), position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled, dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = {
@@ -234,12 +271,8 @@ private fun CalendarsList(
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 // Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up. // safety net. Offered only when there is something to back up.
@@ -259,19 +292,46 @@ private fun CalendarsList(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Synced calendars — read-only, grouped by account, each with a // Synced calendars — read-only, grouped by account. Each account is a
// per-account "manage in source app" link. // 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)) SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) HintText(stringResource(R.string.calendars_synced_hint))
synced synced
.groupBy { it.accountName.ifBlank { it.accountType } } .groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) -> .forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType) 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,
bodyHasRows = true,
headerDisabled = accountDisabled,
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
manageLabel = stringResource(R.string.calendars_manage_in_app),
onManage = {
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
},
onToggleExpand = {
collapsedAccounts = if (expanded) {
collapsedAccounts + account
} else {
collapsedAccounts - account
}
},
showToggleAll = true,
allEnabled = cals.none { it.id in disabledIds },
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
) {
cals.forEachIndexed { index, calendar -> cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = positionOf(index, cals.size), position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled, dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = {
@@ -284,15 +344,7 @@ private fun CalendarsList(
) )
} }
} }
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 +550,178 @@ 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 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].
*
* 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 @Composable
private fun AccountHeader(account: String, accountType: String) { private fun CalendarGroup(
val context = LocalContext.current title: String,
Row( expanded: Boolean,
modifier = Modifier bodyHasRows: Boolean,
.fillMaxWidth() headerDisabled: Boolean,
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), leading: @Composable () -> Unit,
verticalAlignment = Alignment.CenterVertically, manageIcon: ImageVector,
) { manageLabel: String,
Text( onManage: () -> Unit,
text = account, onToggleExpand: () -> Unit,
style = MaterialTheme.typography.titleSmall, showToggleAll: Boolean,
fontWeight = FontWeight.SemiBold, allEnabled: Boolean,
modifier = Modifier.weight(1f), onToggleAll: (Boolean) -> Unit,
body: @Composable ColumnScope.() -> Unit,
) {
GroupedRow(
title = title,
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
dimmed = headerDisabled,
leading = leading,
trailing = {
CalendarGroupMenu(
title = title,
showToggleAll = showToggleAll,
allEnabled = allEnabled,
onToggleAll = onToggleAll,
manageIcon = manageIcon,
manageLabel = manageLabel,
onManage = onManage,
)
},
onClick = onToggleExpand,
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
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()
},
) )
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))
} }
} }
} }
/** 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 @Composable
private fun LeadingAvatar(icon: ImageVector) { private fun LeadingAvatar(icon: ImageVector) {
Box( Box(
@@ -542,25 +740,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 @Composable
private fun SectionHeader(text: String) { private fun SectionHeader(text: String) {
Text( Text(

View File

@@ -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<Long>, 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) { private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
try { try {

View File

@@ -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
}

View File

@@ -140,6 +140,7 @@ fun GroupedRow(
summary: String? = null, summary: String? = null,
selected: Boolean = false, selected: Boolean = false,
dimmed: Boolean = false, dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp, minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
@@ -198,10 +199,10 @@ fun GroupedRow(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.then(gap) .then(gap)
val containerColor = if (selected) { val containerColor = when {
MaterialTheme.colorScheme.secondaryContainer selected -> MaterialTheme.colorScheme.secondaryContainer
} else { container != null -> container
MaterialTheme.colorScheme.surfaceContainerHigh else -> MaterialTheme.colorScheme.surfaceContainerHigh
} }
if (onClick != null) { if (onClick != null) {
Surface( Surface(

View File

@@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette 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.Person
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place 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.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown 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.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.OptionCard
@@ -1019,9 +1020,9 @@ private fun FieldPickerDialog(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
hiddenFields.forEach { field -> hiddenFields.forEach { field ->
OptionCard( OptionCard(
label = stringResource(fieldLabel(field)), label = stringResource(eventFormFieldLabel(field)),
onClick = { onSelect(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 * Visibility selector: one card per level, each with its own icon; the
* current level is highlighted. Tap picks and closes. * current level is highlighted. Tap picks and closes.

View File

@@ -39,6 +39,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CalendarMonth 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.Check
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore 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.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale 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.DayOfWeek
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -220,6 +223,12 @@ private fun SettingsHub(
onClick = onManageCalendars, onClick = onManageCalendars,
) )
LanguageRow(position = Position.Middle) 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) ReportProblemRow(position = Position.Bottom)
AppVersionText() 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 @Composable
private fun LanguageRow(position: Position) { private fun LanguageRow(position: Position) {
val context = LocalContext.current val context = LocalContext.current
@@ -612,8 +639,17 @@ private fun EventFormScreen(
fields.forEachIndexed { index, field -> fields.forEachIndexed { index, field ->
val checked = field in state.defaultFormFields val checked = field in state.defaultFormFields
GroupedRow( GroupedRow(
title = stringResource(formFieldLabel(field)), title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, fields.size), 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 = { trailing = {
Switch( Switch(
checked = checked, 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 showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) } var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) } var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
CollapsingScaffold( CollapsingScaffold(
@@ -746,21 +769,73 @@ private fun NotificationsScreen(
onClick = { showAllDayReminderTime = true }, onClick = { showAllDayReminderTime = true },
) )
// Per-calendar overrides: each writable calendar may keep, drop, or // Delivery reliability + snooze: both are global reminder-delivery
// replace the global default — separately for timed and all-day events. // 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(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Top,
trailing = if (batteryExempt) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
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()) { if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( GroupedRow(
text = stringResource(R.string.settings_calendar_reminders_hint), title = stringResource(R.string.settings_calendar_reminders_title),
style = MaterialTheme.typography.bodyMedium, summary = stringResource(R.string.settings_calendar_reminders_hint),
color = MaterialTheme.colorScheme.onSurfaceVariant, position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), 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 -> state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
val expanded = calendar.id in expandedCalendars val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list of three // Calendar card; tapping expands it into a grouped list
// (the card + the timed and all-day override rows). // of three (the card + the timed and all-day rows).
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone, position = if (expanded) Position.Top else Position.Alone,
@@ -804,43 +879,8 @@ private fun NotificationsScreen(
} }
} }
} }
// 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.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Alone,
trailing = if (batteryExempt) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
} }
} else { }
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
// 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,
onClick = { showSnooze = true },
)
} }
if (showSnooze) { if (showSnooze) {
@@ -1061,17 +1101,6 @@ private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(intent) } 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 @Composable
private fun themeLabel(mode: ThemeMode): String = stringResource( private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) { when (mode) {

View File

@@ -309,6 +309,7 @@
<string name="reminder_custom_amount">Amount</string> <string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string> <string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string> <string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string> <string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string> <string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Reliable delivery</string> <string name="settings_reliable_delivery">Reliable delivery</string>
@@ -345,7 +346,10 @@
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string> <string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
<string name="calendars_synced_header">Synced calendars</string> <string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string> <string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage</string> <string name="calendars_manage_in_app">Manage in app</string>
<string name="calendars_account_menu_a11y">More options for %1$s</string>
<string name="calendars_enable_all">Enable all</string>
<string name="calendars_disable_all">Disable all</string>
<string name="calendars_add_account">Add account</string> <string name="calendars_add_account">Add account</string>
<string name="calendars_new_title">New calendar</string> <string name="calendars_new_title">New calendar</string>
<string name="calendars_edit_title">Edit calendar</string> <string name="calendars_edit_title">Edit calendar</string>