From 22bbfef3e2cf78f2ce7d55a151e39f1cce1b88d3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:12:08 +0200 Subject: [PATCH] feat(contacts): settings sub-page for contact special dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../calendula/ui/settings/SettingsScreen.kt | 269 +++++++++++++++++- .../calendula/ui/settings/SettingsUiState.kt | 18 ++ .../ui/settings/SettingsViewModel.kt | 82 ++++++ app/src/main/res/values/strings.xml | 27 ++ 4 files changed, 395 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index cbc5048..92220c5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -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(null) } + var editTemplate by remember { mutableStateOf(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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 4ad49a7..ad153ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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.entries.toSet(), + val titleTemplates: Map = 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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index ae33454..9e80d2e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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 = 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) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1218ea..48b6856 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,6 +346,33 @@ Theme, default view, week start Default fields for new events Event reminders + Contact birthdays & anniversaries + + + Contact special dates + Show contact dates + 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. + Birthdays + Anniversaries + Other dates + Title format + Use {name} for the contact and {age} for their age (hidden when the year is unknown). + Show age + Include {age} in titles when the birth year is known + Sync now + Not synced yet + + Last synced %1$s + Set each calendar\'s colour, visibility and reminders in Calendars settings. + Paused + Calendula can no longer read your contacts, so these calendars aren\'t updating. + Grant access + Turn off contact dates? + This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost. + + This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost. + Turn off + Save About License MIT