feat(contacts): settings sub-page for contact special dates

A dedicated sub-page under Settings drives the mirror:

- master enable toggle — requests READ_CONTACTS contextually (only here, never at
  startup), runs an immediate sync on enable, and on disable confirms then tears
  the managed calendars down;
- per-type toggles (Birthdays / Anniversaries / Other), each confirm-guarded
  since turning one off deletes its calendar;
- an editable title template ({name}/{age}) and a Show-age toggle;
- a "Sync now" row with the last-run time, a paused/permission banner with a
  re-grant button, and a pointer to per-calendar colour/visibility/reminders.

Exposed via a separate SpecialDatesUiState flow (the main settings combine is
already at capacity); the ViewModel gained the engine/scheduler wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:12:08 +02:00
parent 4da9abe05b
commit 22bbfef3e2
4 changed files with 395 additions and 1 deletions

View File

@@ -38,6 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
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.Cake
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check
@@ -91,6 +92,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
@@ -121,7 +123,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications }
private enum class SettingsSection { Appearance, EventForm, Notifications, SpecialDates }
/**
* Token-based accent for a leading icon chip (container / on-container pair).
@@ -180,6 +182,13 @@ fun SettingsScreen(
) {
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
}
}
}
@@ -225,6 +234,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
onClick = onManageCalendars,
)
GroupedRow(
title = stringResource(R.string.settings_section_special_dates),
summary = stringResource(R.string.settings_special_dates_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.SpecialDates) },
)
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
@@ -1025,6 +1041,257 @@ private fun NotificationsScreen(
}
}
// ---------------------------------------------------------------------------
// Contact special dates (issue #15)
// ---------------------------------------------------------------------------
@Composable
private fun SpecialDatesScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val context = LocalContext.current
// READ_CONTACTS is requested only here, on enable — never at startup. On a
// grant we either enable (if turning on) or just re-sync (clearing a stalled
// banner after the permission was re-granted).
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
}
}
val requestOrEnable: () -> Unit = {
if (hasContactsPermission(context)) {
viewModel.setSpecialDatesEnabled(true)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
var confirmDisableAll by remember { mutableStateOf(false) }
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_special_dates),
onBack = onBack,
) {
// Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.settings_special_dates_paused_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.settings_special_dates_paused_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
) { Text(stringResource(R.string.settings_special_dates_grant)) }
}
}
Spacer(Modifier.height(16.dp))
}
GroupedRow(
title = stringResource(R.string.settings_special_dates_enable),
summary = stringResource(R.string.settings_special_dates_enable_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.enabled,
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
)
},
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
)
if (state.enabled) {
Spacer(Modifier.height(24.dp))
// Per-type toggles + their title templates.
SpecialDateType.entries.forEachIndexed { index, type ->
val on = type in state.types
GroupedRow(
title = stringResource(specialDateTypeLabel(type)),
position = if (index == 0) Position.Top else Position.Middle,
trailing = {
Switch(
checked = on,
onCheckedChange = { want ->
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
else confirmDisableType = type
},
)
},
onClick = {
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
},
)
}
GroupedRow(
title = stringResource(R.string.settings_special_dates_template),
summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(),
position = Position.Bottom,
onClick = { editTemplate = SpecialDateType.Birthday },
)
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_special_dates_show_age),
summary = stringResource(R.string.settings_special_dates_show_age_hint),
position = Position.Top,
trailing = {
Switch(
checked = state.showAge,
onCheckedChange = viewModel::setSpecialDatesShowAge,
)
},
onClick = { viewModel.setSpecialDatesShowAge(!state.showAge) },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_sync_now),
summary = specialDatesLastRunLabel(context, state.lastRun),
position = Position.Bottom,
onClick = viewModel::syncSpecialDatesNow,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.settings_special_dates_calendar_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (confirmDisableAll) {
SpecialDatesDisableDialog(
message = stringResource(R.string.settings_special_dates_disable_all_message),
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
onDismiss = { confirmDisableAll = false },
)
}
confirmDisableType?.let { type ->
SpecialDatesDisableDialog(
message = stringResource(
R.string.settings_special_dates_disable_type_message,
stringResource(specialDateTypeLabel(type)),
),
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
onDismiss = { confirmDisableType = null },
)
}
editTemplate?.let { type ->
SpecialDatesTemplateDialog(
initial = state.titleTemplates[type].orEmpty(),
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
onDismiss = { editTemplate = null },
)
}
}
@Composable
private fun SpecialDatesDisableDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
text = { Text(message) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = onConfirm) {
Text(stringResource(R.string.settings_special_dates_disable_confirm))
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun SpecialDatesTemplateDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf(initial) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_special_dates_template)) },
text = {
Column {
androidx.compose.material3.OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.settings_special_dates_template_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { onConfirm(text) },
enabled = text.isNotBlank(),
) { Text(stringResource(R.string.dialog_save)) }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
}
private fun hasContactsPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
@Composable
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
if (lastRun <= 0L) {
stringResource(R.string.settings_special_dates_never_synced)
} else {
stringResource(
R.string.settings_special_dates_last_synced,
android.text.format.DateUtils.getRelativeTimeSpanString(
lastRun,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString(),
)
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)

View File

@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -70,3 +71,20 @@ data class SettingsUiState(
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
)
/**
* State for the contact special-dates sub-page (issue #15). Exposed as its own
* flow rather than folded into [SettingsUiState] (whose combine is already full).
* [titleTemplates] is always populated — a blank stored template resolves to the
* localized default so the editor never shows an empty field.
*/
data class SpecialDatesUiState(
val enabled: Boolean = false,
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
val showAge: Boolean = true,
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
val stalledPermission: Boolean = false,
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long = 0L,
)

View File

@@ -10,14 +10,22 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -41,6 +49,9 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -166,6 +177,77 @@ class SettingsViewModel @Inject constructor(
val dimCompletedEvents: Boolean,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
prefs.specialDatesEnabled,
prefs.specialDatesTypes,
prefs.specialDatesTitleTemplates,
prefs.specialDatesShowAge,
prefs.specialDatesStatus,
) { enabled, types, templates, showAge, status ->
SpecialDatesUiState(
enabled = enabled,
types = types,
// A blank stored template resolves to the localized default so the
// editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type ->
templates[type]?.takeIf { it.isNotBlank() }
?: specialDatesSpec.defaultTitleTemplate(type)
},
showAge = showAge,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SpecialDatesUiState(),
)
/**
* Turn the mirror on or off. Enabling schedules the daily job and runs an
* immediate sync (which creates the calendars); disabling cancels the job and
* tears the managed calendars down. The caller must have READ_CONTACTS granted
* before enabling.
*/
fun setSpecialDatesEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesEnabled(enabled)
if (enabled) {
SpecialDatesScheduler.apply(appContext, enabled = true)
SpecialDatesScheduler.runNow(appContext)
} else {
SpecialDatesScheduler.apply(appContext, enabled = false)
withContext(io) { specialDatesEngine.teardown() }
}
}
}
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDateTypeEnabled(type, enabled)
// Reconcile now: create the new type's calendar, or delete a disabled one.
SpecialDatesScheduler.runNow(appContext)
}
}
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
viewModelScope.launch {
prefs.setSpecialDatesTitleTemplate(type, template.trim())
SpecialDatesScheduler.runNow(appContext)
}
}
fun setSpecialDatesShowAge(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesShowAge(enabled)
SpecialDatesScheduler.runNow(appContext)
}
}
/** Kick off an immediate reconcile (the "Sync now" action). */
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}

View File

@@ -346,6 +346,33 @@
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>
<!-- Contact special dates (issue #15) -->
<string name="settings_section_special_dates">Contact special dates</string>
<string name="settings_special_dates_enable">Show contact dates</string>
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
<string name="settings_special_dates_type_birthday">Birthdays</string>
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
<string name="settings_special_dates_type_custom">Other dates</string>
<string name="settings_special_dates_template">Title format</string>
<string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string>
<string name="settings_special_dates_show_age">Show age</string>
<string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string>
<string name="settings_special_dates_sync_now">Sync now</string>
<string name="settings_special_dates_never_synced">Not synced yet</string>
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour, visibility and reminders in Calendars settings.</string>
<string name="settings_special_dates_paused_title">Paused</string>
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
<string name="settings_special_dates_grant">Grant access</string>
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
<!-- %1$s is the date type, e.g. "Birthdays". -->
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
<string name="settings_special_dates_disable_confirm">Turn off</string>
<string name="dialog_save">Save</string>
<string name="settings_section_about">About</string>
<string name="settings_license">License</string>
<string name="settings_license_value">MIT</string>