feat(contacts): show source year instead of age in titles
A FREQ=YEARLY event has one static title, so a per-occurrence age is impossible
without heavy per-year exception rows. Replace {age} with {year} — the birth
year (or an anniversary's start year) — which is static and correct on every
occurrence, and shows everywhere (widgets, other apps, exports).
- renderSpecialDateTitle substitutes {year}; drop the age snapshot computation
and the sync `today` parameter.
- Default templates become "{name}'s birthday ({year})" / "…anniversary ({year})".
- Rename the setting to "Show year" (prefs specialDatesShowYear) and remove the
now-unneeded {age} snapshot disclaimer in the template editor.
- Update tests, CHANGELOG and the design-doc note.
lint + unit tests + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,10 +17,7 @@ import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||
@@ -97,9 +94,8 @@ class SpecialDatesSyncWorker(
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
return try {
|
||||
val result = deps.syncEngine().sync(today)
|
||||
val result = deps.syncEngine().sync()
|
||||
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||
SpecialDatesStalledReason.PermissionRevoked
|
||||
} else {
|
||||
|
||||
@@ -14,12 +14,10 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ageAtNextOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.ZoneId
|
||||
@@ -58,11 +56,10 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
) {
|
||||
|
||||
/**
|
||||
* Reconcile every enabled type against the device's contacts. [today] anchors
|
||||
* the age snapshot. Returns why it stopped early (disabled / permission gone)
|
||||
* or [SpecialDatesSyncResult.Success].
|
||||
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||
*/
|
||||
suspend fun sync(today: LocalDate): SpecialDatesSyncResult {
|
||||
suspend fun sync(): SpecialDatesSyncResult {
|
||||
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||
|
||||
@@ -72,7 +69,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||
val reminderCtx = readReminderContext()
|
||||
val templates = prefs.specialDatesTitleTemplates.first()
|
||||
val showAge = prefs.specialDatesShowAge.first()
|
||||
val showYear = prefs.specialDatesShowYear.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = templates[type]?.takeIf { it.isNotBlank() }
|
||||
@@ -81,9 +78,8 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
contactsOfType = desiredByType[type].orEmpty(),
|
||||
today = today,
|
||||
template = template,
|
||||
showAge = showAge,
|
||||
showYear = showYear,
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
@@ -165,13 +161,12 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
contactsOfType: List<ContactSpecialDate>,
|
||||
today: LocalDate,
|
||||
template: String,
|
||||
showAge: Boolean,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
) {
|
||||
val built = contactsOfType.map { sd ->
|
||||
buildManagedEvent(calendarId, type, sd, today, template, showAge, reminderCtx)
|
||||
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||
}
|
||||
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||
val formByUid = built.associate { it.desired.uid to it.form }
|
||||
@@ -186,15 +181,15 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
sd: ContactSpecialDate,
|
||||
today: LocalDate,
|
||||
template: String,
|
||||
showAge: Boolean,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
): BuiltManagedEvent {
|
||||
val anchor = sd.anchorDate()
|
||||
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||
val age = if (showAge) sd.ageAtNextOccurrence(today) else null
|
||||
val title = renderSpecialDateTitle(template, sd.displayName, age)
|
||||
// The source year is static and correct on every occurrence (unlike age).
|
||||
val year = if (showYear) sd.year else null
|
||||
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||
val form = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
|
||||
@@ -476,11 +476,11 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[titleTemplateKey(type)] = template }
|
||||
}
|
||||
|
||||
/** Whether `{age}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||
val specialDatesShowAge: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_AGE_KEY] ?: true }
|
||||
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||
|
||||
suspend fun setSpecialDatesShowAge(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_SHOW_AGE_KEY] = enabled }
|
||||
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Outcome of the last mirror sync, for the settings status line. */
|
||||
@@ -571,7 +571,7 @@ class SettingsPrefs @Inject constructor(
|
||||
const val MIN_BACKUP_INTERVAL = 30L
|
||||
|
||||
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||
internal val SPECIAL_DATES_SHOW_AGE_KEY = booleanPreferencesKey("special_dates_show_age")
|
||||
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.number
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
@@ -21,30 +20,18 @@ fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||
|
||||
/**
|
||||
* The age the contact reaches on the *upcoming* occurrence of this date,
|
||||
* relative to [today], or null when the year is unknown. Because a
|
||||
* `FREQ=YEARLY` event carries a single static title, this is a snapshot taken
|
||||
* at sync time (refreshed on the next sync), not a per-occurrence value.
|
||||
*/
|
||||
fun ContactSpecialDate.ageAtNextOccurrence(today: LocalDate): Int? {
|
||||
val birthYear = year ?: return null
|
||||
val monthDayPassedThisYear = month < today.month.number ||
|
||||
(month == today.month.number && day < today.day)
|
||||
val nextYear = if (monthDayPassedThisYear) today.year + 1 else today.year
|
||||
return nextYear - birthYear
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a title [template] for a contact, substituting `{name}` and `{age}`
|
||||
* (the latter empty when [age] is null). Collapses the whitespace the empty
|
||||
* `{age}` may leave behind, so "{name}'s birthday ({age})" degrades cleanly to
|
||||
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||
* "Jane's birthday" when no year is known.
|
||||
*/
|
||||
fun renderSpecialDateTitle(template: String, name: String, age: Int?): String =
|
||||
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{age}", age?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {age}, then tidy spacing.
|
||||
.replace("{year}", year?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||
.replace(Regex("""\(\s*\)"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
|
||||
@@ -1186,16 +1186,16 @@ private fun SpecialDatesScreen(
|
||||
|
||||
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),
|
||||
title = stringResource(R.string.settings_special_dates_show_year),
|
||||
summary = stringResource(R.string.settings_special_dates_show_year_hint),
|
||||
position = Position.Top,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showAge,
|
||||
onCheckedChange = viewModel::setSpecialDatesShowAge,
|
||||
checked = state.showYear,
|
||||
onCheckedChange = viewModel::setSpecialDatesShowYear,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setSpecialDatesShowAge(!state.showAge) },
|
||||
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||
@@ -1302,16 +1302,6 @@ private fun SpecialDatesTemplateDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only warn about {age}'s snapshot behaviour once the user has
|
||||
// actually put it in the template.
|
||||
if (text.contains("{age}")) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_template_age_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -91,7 +91,8 @@ data class SpecialDatesUiState(
|
||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
||||
val showAge: Boolean = true,
|
||||
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||
val showYear: 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. */
|
||||
|
||||
@@ -186,9 +186,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.specialDatesEnabled,
|
||||
prefs.specialDatesTypes,
|
||||
prefs.specialDatesTitleTemplates,
|
||||
prefs.specialDatesShowAge,
|
||||
prefs.specialDatesShowYear,
|
||||
prefs.specialDatesStatus,
|
||||
) { enabled, types, templates, showAge, status ->
|
||||
) { enabled, types, templates, showYear, status ->
|
||||
SpecialDatesUiState(
|
||||
enabled = enabled,
|
||||
types = types,
|
||||
@@ -198,7 +198,7 @@ class SettingsViewModel @Inject constructor(
|
||||
templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: specialDatesSpec.defaultTitleTemplate(type)
|
||||
},
|
||||
showAge = showAge,
|
||||
showYear = showYear,
|
||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||
lastRun = status.lastRun,
|
||||
)
|
||||
@@ -256,9 +256,9 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesShowAge(enabled: Boolean) {
|
||||
fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesShowAge(enabled)
|
||||
prefs.setSpecialDatesShowYear(enabled)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user