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 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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user