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:
@@ -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,27 @@ 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.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
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
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.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 +97,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 +162,7 @@ fun CalendarsScreen(
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetDisabled = viewModel::setDisabled,
|
||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -166,9 +181,14 @@ private fun CalendarsList(
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetDisabled: (Long, Boolean) -> Unit,
|
||||
onSetAccountDisabled: (Collection<Long>, 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<String>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
LaunchedEffect(error) {
|
||||
@@ -208,20 +228,37 @@ 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))
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
|
||||
// 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()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
}
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
val localCount = local.size + 1
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, localCount),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
@@ -234,12 +271,8 @@ private fun CalendarsList(
|
||||
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,19 +292,46 @@ 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)
|
||||
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 ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
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
|
||||
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,
|
||||
bodyHasRows: Boolean,
|
||||
headerDisabled: Boolean,
|
||||
leading: @Composable () -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
onToggleExpand: () -> Unit,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
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
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
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
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
|
||||
@@ -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) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<OverrideTarget?>(null) }
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
|
||||
CollapsingScaffold(
|
||||
@@ -746,21 +769,73 @@ 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.
|
||||
// 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(
|
||||
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()) {
|
||||
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),
|
||||
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 override rows).
|
||||
// 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,
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</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_reminder_inherits">Default (%1$s)</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_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_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_new_title">New calendar</string>
|
||||
<string name="calendars_edit_title">Edit calendar</string>
|
||||
|
||||
Reference in New Issue
Block a user